diff --git a/.env.example b/.env.example
index 1f151b70..0f65804f 100644
--- a/.env.example
+++ b/.env.example
@@ -86,26 +86,33 @@ ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b
# =============================================================================
# Grok (xAI) Provider — optional
# =============================================================================
-# RoboCo can run agents on grok-build-0.1 (xAI) via the opencode runtime instead
-# of Claude Code. The xAI API key is NOT set here — store it encrypted per
-# project from the panel (provider key), the same as the Ollama/Anthropic keys.
-# Every var below is optional; defaults shown.
+# RoboCo can run agents on Grok Build (xAI) via xAI's official `grok` CLI on the
+# SuperGrok subscription, instead of Claude Code. No metered xAI API key is used:
+# the CLI authenticates from a mounted ~/.grok/auth.json — run `grok login` once
+# on the host (auth.json auto-refreshes). Every var below is optional.
-# Image the orchestrator spawns for Grok agents.
+# Host dir holding the SuperGrok auth. The orchestrator mounts
/auth.json
+# read-only into each Grok agent's ~/.grok. GROK_AUTH_DIR is the host source the
+# compose mounts into the orchestrator; keep both equal to the host's ~/.grok.
+# ROBOCO_HOST_GROK_DIR=/home/youruser/.grok
+# GROK_AUTH_DIR=/home/youruser/.grok
+
+# Image the orchestrator spawns for Grok agents, and the CLI model id.
# ROBOCO_GROK_AGENT_IMAGE=roboco-agent-grok:latest
+# ROBOCO_GROK_CLI_MODEL=grok-build
-# opencode tool permissions for Grok agents: allow | ask | deny. Defaults are
-# "allow"; tighten bash to "deny"/"ask" to fail closed on untrusted repos (the
-# secret-scrub plugin is a denylist, not a full sandbox).
-# ROBOCO_GROK_BASH_PERMISSION=allow
-# ROBOCO_GROK_EDIT_PERMISSION=allow
-# ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=allow
+# Per-role tool permissions are computed as native grok flags (subagents off;
+# edit/shell removed for non-coding roles; raw git mutation + rm -rf denied for
+# coding roles) — there is nothing to set here.
-# Force one reasoning effort for ALL Grok agents: minimal | high | max (or empty
-# for opencode's default). Empty = per-role: coordination/docs roles request
-# "minimal" to cut reasoning cost, code roles keep full reasoning.
+# Force one reasoning effort for ALL Grok agents: low | medium | high | xhigh |
+# max (or empty for the per-role default — coordination/docs/board roles request
+# "low" to cut reasoning cost, code roles keep full reasoning).
# ROBOCO_GROK_REASONING_EFFORT=
+# Hard ceiling on agentic turns per run (loop guard).
+# ROBOCO_GROK_MAX_TURNS=200
+
# Kill a Grok agent container after this many seconds idle (no model call /
# stream) to reclaim a wedged one. Minimum 120.
# ROBOCO_GROK_IDLE_KILL_SECONDS=900
diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml
index 38ed3f48..ea018b42 100644
--- a/docker-compose.registry.yml
+++ b/docker-compose.registry.yml
@@ -209,6 +209,9 @@ services:
# absolute paths on the host. Default to this compose project's ./data.
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/opt/roboco}
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-${HOME}/.claude}
+ # SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
+ # mounts /auth.json into each Grok agent. Run `grok login` on the host.
+ ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/opt/roboco/data}
# Reachable base URL for commit-trailer links — set to your host's LAN
# address or domain so the links in commit bodies resolve.
@@ -237,12 +240,16 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CLAUDE_AUTH_DIR:-${HOME}/.claude}:/root/.claude
+ # SuperGrok auth — mount host ~/.grok at the SAME host path the orchestrator
+ # hands each Grok agent's `-v`, so its auth.json exists() check passes here
+ # AND the agent bind resolves on the host. `grok login` on the host. RO.
+ - ${GROK_AUTH_DIR:-${HOME}/.grok}:${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}:ro
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
- # Per-agent opencode stores (GROK usage/cost capture).
- - ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
+ # Per-agent GROK usage capture (usage.json -> finalizer).
+ - ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
- ${ROBOCO_DATA_DIR:-./data}/briefings:/app/briefings
- ${ROBOCO_DATA_DIR:-./data}/manifests:/app/manifests
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 7fda020a..66451a4d 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -230,7 +230,7 @@ services:
- agent-base-image
# ==========================================================================
- # Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol)
+ # Agent Grok Image Builder (xAI Grok Build via the official grok CLI)
# ==========================================================================
agent-grok-image:
build:
@@ -242,8 +242,8 @@ services:
depends_on:
- agent-base-image
- # Interactive Grok roles (intake/secretary) — opencode-serve sessions; built
- # FROM roboco-agent-grok, so they depend on the Grok runtime image.
+ # Interactive Grok roles (intake/secretary) — panel-driven grok-CLI sessions;
+ # built FROM roboco-agent-grok, so they depend on the Grok runtime image.
agent-grok-prompter-image:
build:
context: .
@@ -306,6 +306,9 @@ services:
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude}
+ # SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
+ # mounts /auth.json into each Grok agent. Run `grok login` on the host.
+ ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
# unusable links in commit message bodies; set to NAS LAN IP so
@@ -344,6 +347,11 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
# Claude Code auth - mount your ~/.claude directory
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
+ # SuperGrok auth — mount the host ~/.grok at the SAME host path the
+ # orchestrator passes to each Grok agent's `-v`, so its auth.json exists()
+ # check passes here AND the agent bind resolves on the host. `grok login`
+ # on the host writes auth.json (auto-refreshing). Read-only.
+ - ${GROK_AUTH_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
# Shared config directory for MCP configs (writable)
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
# Generated prompts directory - composed at runtime from layers
@@ -352,9 +360,9 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
- # Per-agent opencode stores (GROK usage/cost capture): each Grok agent
- # writes opencode.db under /; the finalizer reads it back here.
- - ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
+ # Per-agent GROK usage capture: each Grok agent writes usage.json under
+ # /; the finalizer reads the captured tokens/cost back here.
+ - ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
# Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`.
diff --git a/docker-compose.yml b/docker-compose.yml
index 7fda020a..66451a4d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -230,7 +230,7 @@ services:
- agent-base-image
# ==========================================================================
- # Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol)
+ # Agent Grok Image Builder (xAI Grok Build via the official grok CLI)
# ==========================================================================
agent-grok-image:
build:
@@ -242,8 +242,8 @@ services:
depends_on:
- agent-base-image
- # Interactive Grok roles (intake/secretary) — opencode-serve sessions; built
- # FROM roboco-agent-grok, so they depend on the Grok runtime image.
+ # Interactive Grok roles (intake/secretary) — panel-driven grok-CLI sessions;
+ # built FROM roboco-agent-grok, so they depend on the Grok runtime image.
agent-grok-prompter-image:
build:
context: .
@@ -306,6 +306,9 @@ services:
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude}
+ # SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
+ # mounts /auth.json into each Grok agent. Run `grok login` on the host.
+ ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
# unusable links in commit message bodies; set to NAS LAN IP so
@@ -344,6 +347,11 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
# Claude Code auth - mount your ~/.claude directory
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
+ # SuperGrok auth — mount the host ~/.grok at the SAME host path the
+ # orchestrator passes to each Grok agent's `-v`, so its auth.json exists()
+ # check passes here AND the agent bind resolves on the host. `grok login`
+ # on the host writes auth.json (auto-refreshing). Read-only.
+ - ${GROK_AUTH_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
# Shared config directory for MCP configs (writable)
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
# Generated prompts directory - composed at runtime from layers
@@ -352,9 +360,9 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
- # Per-agent opencode stores (GROK usage/cost capture): each Grok agent
- # writes opencode.db under /; the finalizer reads it back here.
- - ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
+ # Per-agent GROK usage capture: each Grok agent writes usage.json under
+ # /; the finalizer reads the captured tokens/cost back here.
+ - ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
# Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`.
diff --git a/docker/agent-grok-prompter.Dockerfile b/docker/agent-grok-prompter.Dockerfile
index 4b5e3749..6f0ec611 100644
--- a/docker/agent-grok-prompter.Dockerfile
+++ b/docker/agent-grok-prompter.Dockerfile
@@ -1,28 +1,19 @@
-# GROK Intake (Prompter) Agent — interactive opencode-serve session on Grok.
+# GROK Intake (Prompter) Agent — interactive grok-CLI session on Grok.
# =============================================================================
-# The Grok analogue of agent-prompter. Unlike the one-shot Grok runtime (a
-# single `opencode run` that exits), this holds a PERSISTENT `opencode serve`
-# session open, receives the human's messages over HTTP (POST /turn on :9000),
-# and streams each reply back to the panel via the relay. Builds on the Grok
-# runtime image (opencode + @ai-sdk/openai + the secret-scrub plugin); the
-# driver renders opencode.json from the spawn env, then drives the session.
+# The Grok analogue of agent-prompter. Unlike the one-shot Grok runtime (a single
+# `grok -p` that exits), this holds a PERSISTENT conversation: it receives the
+# human's messages over HTTP (POST /turn on :9000) and, per turn, runs a headless
+# `grok -p` that resumes one session id, streaming each reply back to the panel
+# via the relay (see roboco.agent_sdk.grok_intake_main + grok_cli_session). The
+# intake `propose_draft` tool is wired as the roboco-intake MCP server (rendered
+# into ~/.grok/config.toml by the driver). Builds on the Grok runtime image
+# (grok CLI + the roboco venv).
# =============================================================================
FROM roboco-agent-grok
-USER root
-
-# The intake propose_draft tool plugin (the model calls it; the driver turns the
-# call into the panel's draft card), baked into the auto-discovery dir so only
-# the intake image carries it. opencode registers tools from this directory, not
-# from a config `plugin:`-array path (verified live).
-COPY docker/grok/intake-tools.js /home/agent/.config/opencode/plugin/intake-tools.js
-RUN chown agent:agent /home/agent/.config/opencode/plugin/intake-tools.js
-
-USER agent
-
LABEL role="grok-prompter"
-LABEL description="Intake interviewer on Grok — a long-lived opencode serve session driven by the panel"
+LABEL description="Intake interviewer on Grok — a panel-driven grok-CLI conversation"
# The in-container receiver the orchestrator delivers the human's turns to.
EXPOSE 9000
diff --git a/docker/agent-grok-secretary.Dockerfile b/docker/agent-grok-secretary.Dockerfile
index d6e9bd4e..354c8914 100644
--- a/docker/agent-grok-secretary.Dockerfile
+++ b/docker/agent-grok-secretary.Dockerfile
@@ -1,29 +1,19 @@
-# GROK Secretary Agent — interactive opencode-serve session on Grok.
+# GROK Secretary Agent — interactive grok-CLI session on Grok.
# =============================================================================
-# The Grok analogue of agent-secretary. Holds a PERSISTENT `opencode serve`
-# session open, receives the CEO's messages over HTTP (POST /turn on :9000), and
-# streams each reply back to the panel via the relay. The Secretary's CEO-
-# authority tools (read_company_state / read_task / submit_directive) are
-# registered as opencode tools by the secretary-tools.js plugin, which calls
-# /api/secretary/* with the container's HMAC agent token. Builds on the Grok
-# runtime image; the driver renders opencode.json from the spawn env first.
+# The Grok analogue of agent-secretary. Holds a PERSISTENT conversation: receives
+# the CEO's messages over HTTP (POST /turn on :9000) and, per turn, runs a
+# headless `grok -p` that resumes one session id, streaming each reply back to the
+# panel via the relay. The Secretary's CEO-authority tools (read_company_state /
+# read_task / submit_directive) are wired as the roboco-secretary MCP server
+# (rendered into ~/.grok/config.toml by the driver), which calls /api/secretary/*
+# with the container's HMAC agent token. Builds on the Grok runtime image
+# (grok CLI + the roboco venv).
# =============================================================================
FROM roboco-agent-grok
-USER root
-
-# The CEO-authority tool plugin (read_company_state / read_task / submit_directive),
-# baked into the auto-discovery dir so ONLY the Secretary image carries it (no
-# other role gets CEO authority). opencode registers it from this directory; a
-# config `plugin:`-array path would not register its tools (verified live).
-COPY docker/grok/secretary-tools.js /home/agent/.config/opencode/plugin/secretary-tools.js
-RUN chown agent:agent /home/agent/.config/opencode/plugin/secretary-tools.js
-
-USER agent
-
LABEL role="grok-secretary"
-LABEL description="Secretary on Grok — a long-lived opencode serve session driven by the panel"
+LABEL description="Secretary on Grok — a panel-driven grok-CLI conversation"
# The in-container receiver the orchestrator delivers the CEO's turns to.
EXPOSE 9000
diff --git a/docker/grok/budget-feed.js b/docker/grok/budget-feed.js
deleted file mode 100644
index b772a9c7..00000000
--- a/docker/grok/budget-feed.js
+++ /dev/null
@@ -1,136 +0,0 @@
-// opencode plugin — budget / loop / terminal feed for one-shot RoboCo Grok agents.
-//
-// Ports the Claude PostToolUse budget hook (docker/scripts/post-tool-budget-hook.sh)
-// and the Stop hook's terminal-tool tracking (docker/scripts/stop-hook.sh) to
-// opencode's tool.execute.{before,after}. The in-container SDK server
-// (roboco.agent_sdk.server, :9000) is the same long-lived process the Claude
-// path runs — the grok entrypoint starts it before `opencode run`. The flow/do
-// MCP servers already POST /verb/attempted to it for the per-verb circuit
-// breaker, so starting it + this feed restores the budget/loop/terminal cluster
-// on Grok (Claude-parity, the CEO's "create what's missing" call).
-//
-// before: read /budget/status and DENY (throw) on a hard halt, or a loop with
-// loop_action=halt. opencode has no PostToolUse-deny, so the pre-exec
-// gate is the only place to stop a runaway one-shot run from burning
-// the cost cap mid-turn. The loop trips one call later than Claude
-// (record is in `after`) but still halts the burn.
-// after: record the executed tool on /terminal/tool_recorded (so a graceful
-// terminal verb is recognized) and /budget/tool_called (advances the
-// breaker/loop counters and feeds the post-exit post-mortem).
-//
-// Fail policy: the `after` POSTs never block (recording can't risk spend). The
-// `before` gate fails OPEN by default, but fails CLOSED when ROBOCO_BUDGET_ENFORCE=1
-// (set by the one-shot entrypoint, which always starts the SDK budget server) and
-// the budget server is unreachable — an unenforceable cost cap on a task agent is
-// the one case worth halting for. Interactive serve images (intake / secretary)
-// own :9000 for the human-turn receiver, run NO SDK budget server, and set no
-// ENFORCE flag, so their tool calls always proceed (these POSTs 404 there).
-
-const SDK_URL = process.env.ROBOCO_SDK_URL || "http://localhost:9000";
-
-async function sdk(method, path, body) {
- try {
- const res = await fetch(`${SDK_URL}${path}`, {
- method,
- headers: body ? { "Content-Type": "application/json" } : undefined,
- body: body ? JSON.stringify(body) : undefined,
- signal: AbortSignal.timeout(2000),
- });
- if (!res.ok) return null;
- return await res.json();
- } catch {
- return null; // fail-open — never block the agent on SDK reachability
- }
-}
-
-// Canonical, dependency-free serialization (sorted keys) so the SDK's loop
-// detector sees identical (tool, args) calls as identical. Need NOT match the
-// Claude hook's sha256 — only be stable within one session.
-function canonical(value) {
- if (value === null || typeof value !== "object") return JSON.stringify(value);
- if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
- const keys = Object.keys(value).sort();
- return (
- "{" +
- keys.map((k) => JSON.stringify(k) + ":" + canonical(value[k])).join(",") +
- "}"
- );
-}
-
-function argsHash(args) {
- const s = canonical(args ?? {});
- let h = 0x811c9dc5; // FNV-1a, 32-bit
- for (let i = 0; i < s.length; i++) {
- h ^= s.charCodeAt(i);
- h = Math.imul(h, 0x01000193);
- }
- return (h >>> 0).toString(16).padStart(8, "0");
-}
-
-// opencode namespaces an MCP tool as "_" (or ".");
-// the Claude path's verbs arrive bare. Strip a known roboco-* server prefix so
-// the SDK recognizes a terminal verb (i_am_idle / i_am_done / ...) — the SDK's
-// own "__"-split is a no-op on the already-bare verb this returns.
-// Verified live: opencode delivers MCP tools as "roboco-flow_" (underscore);
-// the "." form and an mcp__ prefix are still handled defensively.
-function bareVerb(tool) {
- const mcp = tool.match(/^mcp__[a-z0-9-]+__(.+)$/);
- if (mcp) return mcp[1];
- const pref = tool.match(/^roboco-[a-z-]+[_.](.+)$/);
- if (pref) return pref[1];
- return tool;
-}
-
-// Release/escape verbs must ALWAYS be allowed through the before-gate. A halt
-// (budget, loop, or fail-closed) that also blocks these traps the agent: it can
-// neither continue nor stop, so it flails — and every blocked retry is another
-// billed model turn. Letting i_am_idle / unclaim / i_am_blocked through is the
-// only way a halted agent can exit cleanly.
-const RELEASE_VERBS = new Set(["i_am_idle", "unclaim", "i_am_blocked"]);
-
-// Named export (opencode's plugin convention) + baked into the plugin
-// auto-discovery dir (~/.config/opencode/plugin/) at image build — the simplest
-// registration route (no config `plugin:` path needed).
-export const RobocoBudgetFeed = async () => {
- return {
- "tool.execute.before": async (input) => {
- // Escape hatches always pass — a halted agent must be able to stop.
- if (RELEASE_VERBS.has(bareVerb(String(input?.tool || "")))) return;
- const status = await sdk("GET", "/budget/status", null);
- if (!status) {
- // One-shot delivery agents MUST have the in-container SDK budget server
- // (the entrypoint starts it and exports ENFORCE=1). A missing signal
- // there means the cost cap is unenforceable — fail CLOSED to stop an
- // uncapped burn. Interactive serve agents set no flag → fail open.
- if (process.env.ROBOCO_BUDGET_ENFORCE === "1") {
- throw new Error(
- "[Halt] budget server unreachable — failing closed to prevent " +
- "uncapped token spend. Stop now with i_am_idle() or unclaim().",
- );
- }
- return; // fail-open (no budget server expected for this role)
- }
- if (status.halt) {
- throw new Error(
- `[Halt] tool budget exhausted (${status.total}/${status.halt_threshold}). ` +
- "Stop now — release the task with unclaim() or i_am_idle().",
- );
- }
- if (status.loop && status.loop_action === "halt") {
- throw new Error(
- "[Loop] same tool+args repeated in window — halting. " +
- "Release the task with unclaim() or stop with i_am_idle().",
- );
- }
- },
- "tool.execute.after": async (input) => {
- const raw = String(input?.tool || "");
- if (!raw) return;
- await sdk("POST", "/terminal/tool_recorded", { tool: bareVerb(raw) });
- await sdk("POST", "/budget/tool_called", {
- tool: bareVerb(raw),
- args_hash: argsHash(input?.args),
- });
- },
- };
-};
diff --git a/docker/grok/intake-tools.js b/docker/grok/intake-tools.js
deleted file mode 100644
index 712aff03..00000000
--- a/docker/grok/intake-tools.js
+++ /dev/null
@@ -1,70 +0,0 @@
-// opencode plugin — the Intake interviewer's propose_draft tool, on Grok.
-//
-// The model calls propose_draft once the task spec is ready; this delivers the
-// draft to the panel's reviewable draft card.
-//
-// WHY IT POSTS DIRECTLY (not via the driver): opencode's synchronous serve reply
-// (POST /session/:id/message) returns only the final assistant text + step
-// markers — NOT the tool-CALL parts. So OpencodeServeSession cannot intercept
-// this call to emit a `draft` chunk (verified live: a propose_draft call comes
-// back as parts=[step-start, text, step-finish], no tool part). Instead the tool
-// POSTs the draft straight to the prompter-live relay — the same
-// /api/prompter/live/{session}/events endpoint the driver's relay sink uses — so
-// the panel renders the card regardless. (The Claude intake path differs: the
-// Claude SDK DOES expose the tool-use block, so its driver intercepts it.)
-//
-// Loaded from the plugin auto-discovery dir (~/.config/opencode/plugin/), baked
-// into the grok-prompter image only (the one-shot delivery roles never draft).
-// The container provides ROBOCO_API_URL + ROBOCO_PROMPTER_SESSION_ID.
-
-import { tool } from "@opencode-ai/plugin";
-
-const API_BASE = (
- process.env.ROBOCO_API_URL || "http://roboco-orchestrator:8000"
-).replace(/\/+$/, "");
-
-export const RobocoIntakeTools = async () => ({
- tool: {
- propose_draft: tool({
- description:
- "Submit the finished task draft for the human to review and confirm. " +
- "Call this once the spec is complete. Pass a JSON object: title, " +
- "objective, what_this_builds[], the_work[] ({team, summary, items}), " +
- "notes[], acceptance_criteria[], team, scale, task_type, nature, " +
- "estimated_complexity, priority.",
- args: {
- draft: tool.schema
- .record(tool.schema.string(), tool.schema.any())
- .describe("The task draft object"),
- },
- async execute(args) {
- const session = process.env.ROBOCO_PROMPTER_SESSION_ID || "";
- if (!session) {
- return "No live session id (ROBOCO_PROMPTER_SESSION_ID) — cannot surface the draft.";
- }
- try {
- const res = await fetch(
- `${API_BASE}/api/prompter/live/${encodeURIComponent(session)}/events`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- kind: "draft",
- text: "",
- tool: "propose_draft",
- data: args.draft || {},
- }),
- signal: AbortSignal.timeout(15000),
- },
- );
- if (!res.ok) {
- return `Draft relay returned HTTP ${res.status}; the human may not see the card.`;
- }
- } catch (e) {
- return "Could not submit the draft to the panel: " + String(e);
- }
- return "Draft submitted — the human can review it in the panel.";
- },
- }),
- },
-});
diff --git a/docker/grok/secret-scrub.js b/docker/grok/secret-scrub.js
deleted file mode 100644
index eb86ccd3..00000000
--- a/docker/grok/secret-scrub.js
+++ /dev/null
@@ -1,192 +0,0 @@
-// opencode plugin — command guard / secret-scrub for RoboCo Grok agents.
-//
-// Ports the security-critical deny rules from docker/scripts/bash-guard-hook.sh
-// (the Claude Code PreToolUse guard) to opencode's `tool.execute.before` hook.
-// Those rules are Claude Code hooks and do NOT transfer to the opencode runtime,
-// so a Grok agent would otherwise run bash unguarded — this restores parity.
-//
-// Mechanism (confirmed by opencode's own env-protection plugin example):
-// throwing inside `tool.execute.before` denies the tool call. For `bash` the
-// command is `output.args.command`; for `read`/`edit` the path is
-// `output.args.filePath`.
-//
-// Baked into the plugin auto-discovery dir (~/.config/opencode/plugin/) at image
-// build (named export, opencode's convention) — the same route as budget-feed.
-// The agent's bash permission is a second gate via ROBOCO_GROK_BASH_PERMISSION.
-//
-// STATUS: the plugin loads in the live runtime (same auto-discovery dir as the
-// live-confirmed budget-feed), but the deny-on-match path has not yet blocked a
-// real command on the NAS — confirm before trusting it as the sole bash gate.
-// Deny-on-match is fail-closed: a false positive blocks a legitimate command
-// (annoying, safe) rather than letting a dangerous one through.
-
-const CREDENTIAL_FILE =
- /(\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519|id_ecdsa|known_hosts)/;
-
-// Secret-bearing files for the source / encode / interpreter rules. Wider than
-// CREDENTIAL_FILE (which also gates Read/Edit *paths*, so it must NOT include
-// .env lest it block reading .env.example): a bash command that READS these is
-// exfiltration. Mirrors the file set in bash-guard-hook.sh's source/interpreter
-// rules.
-const SECRET_FILE =
- /(\.env\b|\/etc\/environment|\.git-credentials|\.netrc|\.git\/config|\.gitconfig|\/proc\/[^\s]*environ|\.profile|\.bashrc|\.zshrc|id_rsa|id_ed25519|id_ecdsa|\.ssh\/)/;
-
-// git network/auth/branch-mutating ops — run against the SKELETONIZED command
-// (see gitSkeleton) so a heredoc/echo that merely documents `git push` is not
-// mistaken for invoking it. Mirrors bash-guard-hook.sh's git-ops rule.
-const GIT_OPS =
- /(^|[\s;&|])git\s+(fetch|pull|push|clone|remote|ls-remote|checkout|commit|merge|rebase|reset|cherry-pick|revert|tag\s+-d|update-ref|reflog\s+delete)/;
-
-const INTERNAL_HOST =
- /((https?|wss?):\/\/)?\/?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]/;
-
-const HTTP_CLIENT_LIB =
- /(httpx|requests|urllib|aiohttp|http\.client|httplib|net\/http|net::http|node-fetch|axios|xmlhttprequest|websocket|fetch\s*\()/;
-
-// Each check takes the lowercased bash command and returns a deny reason, or
-// null to allow. Mirrors the categories in bash-guard-hook.sh.
-const BASH_CHECKS = [
- // (git-ops is checked first in denyBash, on the skeletonized command.)
- (low) =>
- CREDENTIAL_FILE.test(low)
- ? "command references a credential file or SSH key — the PAT is injected subprocess-side by the MCP layer, never read from these files."
- : null,
- (low) =>
- /(^|[\s;&|])(source|\.)\s+[^|;&]*/.test(low) && SECRET_FILE.test(low)
- ? "sourcing a credential-bearing file (.env / .git-credentials / .netrc / ...) exposes secrets in the current shell."
- : null,
- (low) =>
- /(^|[\s;&|])(python3?|perl|node|ruby|awk|sed)\s+[^|;&]*-[ce]\s/.test(low) &&
- SECRET_FILE.test(low)
- ? "interpreter one-liner reads a credential file — ask for the value you need via the task description."
- : null,
- (low) =>
- /\/proc\/(self|\d+|\$\$)\/(environ|cmdline|cwd|exe)/.test(low)
- ? "reading /proc/*/environ or /proc/*/cmdline can leak credentials."
- : null,
- (low) =>
- /(^|[\s;&|])(curl|wget|http|https|httpie)\s[^|]*(github\.com|api\.github\.com)/.test(
- low,
- )
- ? "direct GitHub HTTP calls bypass the PAT handler — use the role-appropriate MCP verb."
- : null,
- (low) =>
- /(^|[\s;&|])(curl|wget|http|https|httpie)\s/.test(low) && INTERNAL_HOST.test(low)
- ? "internal API calls bypass the gateway — use the MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal)."
- : null,
- (low) =>
- HTTP_CLIENT_LIB.test(low) && INTERNAL_HOST.test(low)
- ? "reaching an internal host via an HTTP client bypasses the gateway, role manifest, tracing and auth (and can forge X-Agent-* headers). Use your MCP verbs."
- : null,
- (low) =>
- /(python3?|uv\s+run|poetry\s+run|pipenv\s+run|pdm\s+run|hatch\s+run)/.test(low) &&
- /(import\s+roboco|from\s+roboco|-m\s+roboco|roboco\.(mcp|services|runtime|foundation|api|enforcement)\b)/.test(
- low,
- )
- ? "importing or running roboco.* internals from the shell bypasses the MCP role manifest, tracing and auth. Use your role's MCP verbs."
- : null,
- (low) =>
- /(^|[\s;&|]|env\s+|export\s+)roboco_agent_id\s*=/.test(low)
- ? "ROBOCO_AGENT_ID is your injected identity — overriding it forges another agent's identity. Never set or export it."
- : null,
- (low) =>
- /(^|[\s;&|])(env|printenv)([\s]|$)/.test(low) &&
- !/(^|[\s;&|])env\s+(-i|[a-z_][a-z0-9_]*=)/.test(low)
- ? "env / printenv can leak secrets. Ask for the specific value you need via the task description."
- : null,
- (low) =>
- /(^|[\s;&|])set([\s]*$|[\s]*[|;&])/.test(low) ||
- /(^|[\s;&|])(declare|typeset)\s+-[a-z]*[xp]/.test(low) ||
- /(^|[\s;&|])export\s+-p([\s]|$)/.test(low) ||
- /(^|[\s;&|])compgen\s+-[a-z]*[ve]/.test(low)
- ? "shell built-ins that dump variables/exports can leak credentials."
- : null,
- (low) =>
- /(^|[\s;&|])(base64|od|xxd|hexdump|strings|uuencode)\s[^|;&]*(\.env|\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519)/.test(
- low,
- )
- ? "encoding/inspecting a credential file is still exfiltration."
- : null,
- (low) =>
- /(^|[\s;&|])rm\s[^|;&]*-[a-z]*[rf][a-z]*\s/.test(low) &&
- /(^|[\s;&|])rm\s[^|;&]*(\/app($|[\s/])|\/root|\/etc|\/var|\/usr|\/bin|\/sbin|\/lib|\/home|\s\/\s*(;|\||&|$))/.test(
- low,
- )
- ? "rm on a system path. Operate inside your own workspace only."
- : null,
-];
-
-// Tools that take a file path we must keep away from credential files.
-const PATH_TOOLS = new Set(["read", "edit", "write"]);
-
-// Strip heredoc bodies and echo/printf literal args BEFORE the git-ops check —
-// those are data the shell writes to a file, not commands it runs, so a
-// README/heredoc that documents `git push` must not be mistaken for invoking
-// it. Quoted args to an interpreter (`bash -c "... && git fetch"`) ARE executed
-// and are not echo/printf/heredoc bodies, so they survive. Mirrors the
-// git_skel logic in bash-guard-hook.sh; every other rule sees the full command.
-function gitSkeleton(command) {
- const lines = String(command || "").split("\n");
- const opener = /<<-?\s*[^\sA-Za-z_]*([A-Za-z_]\w*)/;
- const kept = [];
- for (let i = 0; i < lines.length; i++) {
- kept.push(lines[i]);
- const m = opener.exec(lines[i]);
- if (m) {
- const delim = m[1];
- const dash = lines[i].includes("<<-");
- i++;
- while (i < lines.length) {
- const body = lines[i];
- const cand = dash ? body.trim() : body;
- if (cand === delim) {
- kept.push(body);
- break;
- }
- i++;
- }
- }
- }
- return kept
- .join("\n")
- .replace(/(^|[\n;&|]|&&|\|\|)\s*(echo|printf)\b[^\n;&|]*/g, "$1");
-}
-
-function denyBash(command) {
- const low = String(command || "").toLowerCase();
- if (!low) return null;
- if (GIT_OPS.test(gitSkeleton(command).toLowerCase())) {
- return "shell git for network/auth/branch-mutating ops is blocked — use your role's MCP verb (commit, complete, i_am_done, ...).";
- }
- for (const check of BASH_CHECKS) {
- const reason = check(low);
- if (reason) return reason;
- }
- return null;
-}
-
-// Named export (opencode's plugin convention) + loaded from opencode's plugin
-// auto-discovery dir (~/.config/opencode/plugin/), where it's baked at image
-// build — the simplest registration route (no config `plugin:` path needed).
-// Hook firing verified live against grok-build-0.1.
-export const RobocoSecretScrub = async () => {
- return {
- "tool.execute.before": async (input, output) => {
- const tool = input?.tool;
- const args = output?.args || {};
- if (tool === "bash") {
- const reason = denyBash(args.command);
- if (reason) throw new Error(`Denied by roboco secret-scrub: ${reason}`);
- return;
- }
- if (PATH_TOOLS.has(tool)) {
- const path = String(args.filePath || args.path || "").toLowerCase();
- if (path && CREDENTIAL_FILE.test(path)) {
- throw new Error(
- "Denied by roboco secret-scrub: access to a credential file / SSH key is blocked.",
- );
- }
- }
- },
- };
-};
diff --git a/docker/grok/secretary-tools.js b/docker/grok/secretary-tools.js
deleted file mode 100644
index 0900e55e..00000000
--- a/docker/grok/secretary-tools.js
+++ /dev/null
@@ -1,118 +0,0 @@
-// opencode plugin — the Secretary's CEO-authority tools, on Grok.
-//
-// Parity with the Claude Secretary's SDK tools (roboco.agent_sdk.secretary_driver
-// .build_secretary_options): read_company_state / read_task / submit_directive,
-// each calling the backend /api/secretary/* routes with the container's HMAC
-// agent token. Without these the Grok Secretary can chat but cannot read company
-// state or act on a CEO directive — the integration blocker.
-//
-// Loaded ONLY into the roboco-agent-grok-secretary image via
-// ROBOCO_OPENCODE_EXTRA_PLUGINS (so no other role gets CEO authority). The
-// container already carries ROBOCO_AGENT_TOKEN / ROBOCO_API_URL / ROBOCO_AGENT_ID
-// / ROBOCO_AGENT_ROLE (set by the orchestrator's _build_secretary_run_cmd), so
-// the auth substrate matches the one-shot Grok path exactly.
-//
-// The backend gate-list queues high-impact directive kinds (charter,
-// control_task, approve_pitch, announce) for the CEO's explicit confirmation and
-// runs relay_message directly — that policy lives server-side; this plugin only
-// forwards the call. Each tool returns the backend JSON as a string the model
-// reads back (mirrors secretary_driver._text_result).
-//
-// Verified live on the NAS: the @opencode-ai/plugin tool-registration path
-// round-trips against a live opencode serve + grok-build-0.1 — a directive
-// reaches the backend with the HMAC token and the JSON result returns to the model.
-
-import { tool } from "@opencode-ai/plugin";
-
-const API_BASE = (
- process.env.ROBOCO_API_URL || "http://roboco-orchestrator:8000"
-).replace(/\/+$/, "");
-const TIMEOUT_MS = 30000;
-
-function headers() {
- const h = {
- "Content-Type": "application/json",
- "X-Agent-ID": process.env.ROBOCO_AGENT_ID || "",
- "X-Agent-Role": process.env.ROBOCO_AGENT_ROLE || "secretary",
- };
- const token = process.env.ROBOCO_AGENT_TOKEN;
- if (token) h["X-Agent-Token"] = token;
- return h;
-}
-
-// Call /api/secretary{path}; never throw — a failure becomes an {error,...}
-// object the model can read and report, exactly like secretary_driver._call_backend.
-async function callBackend(method, path, body) {
- let res;
- try {
- res = await fetch(`${API_BASE}/api/secretary${path}`, {
- method,
- headers: headers(),
- body: body === undefined ? undefined : JSON.stringify(body),
- signal: AbortSignal.timeout(TIMEOUT_MS),
- });
- } catch (e) {
- return { error: "request_failed", detail: String(e) };
- }
- let data;
- try {
- data = await res.json();
- } catch {
- data = { detail: await res.text().catch(() => "") };
- }
- if (!res.ok) return { error: `http_${res.status}`, detail: data };
- return data;
-}
-
-const asText = (data) => JSON.stringify(data);
-
-// Named export (opencode's plugin convention) + baked into the plugin
-// auto-discovery dir (~/.config/opencode/plugin/) at image build. Verified live
-// against grok-build-0.1: the model called read_company_state + submit_directive
-// and the backend received both requests with the X-Agent-Token.
-export const RobocoSecretaryTools = async () => ({
- tool: {
- read_company_state: tool({
- description:
- "Read a compact snapshot of company state: the charter (goals), task " +
- "counts by status, pending pitches, and any directives awaiting the " +
- "CEO's confirmation.",
- args: {},
- async execute() {
- return asText(await callBackend("GET", "/state"));
- },
- }),
- read_task: tool({
- description: "Read one task's detail by its id.",
- args: { task_id: tool.schema.string().describe("The task id") },
- async execute(args) {
- const id = encodeURIComponent(String(args.task_id));
- return asText(await callBackend("GET", `/tasks/${id}`));
- },
- }),
- submit_directive: tool({
- description:
- "Act on the CEO's command. 'kind' is one of: relay_message " +
- "(payload: channel, text), update_charter (payload: charter), " +
- "control_task (payload: task_id, action[start|cancel|override], " +
- "status?), approve_pitch (payload: pitch_id, notes?), announce " +
- "(payload: text). High-impact kinds (charter, control_task, " +
- "approve_pitch, announce) are queued for the CEO's explicit " +
- "confirmation; relay_message runs directly.",
- args: {
- kind: tool.schema.string().describe("The directive kind"),
- payload: tool.schema
- .record(tool.schema.string(), tool.schema.any())
- .describe("The directive payload object"),
- },
- async execute(args) {
- return asText(
- await callBackend("POST", "/directives", {
- kind: args.kind,
- payload: args.payload || {},
- }),
- );
- },
- }),
- },
-});
diff --git a/docker/scripts/grok-agent-entrypoint.sh b/docker/scripts/grok-agent-entrypoint.sh
deleted file mode 100755
index 7a3a19e6..00000000
--- a/docker/scripts/grok-agent-entrypoint.sh
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/usr/bin/env bash
-# Entrypoint for the roboco-agent-grok image (one-shot delivery roles).
-#
-# Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by
-# GrokProvider) plus the mounted Claude Code mcp-config.json, starts the
-# in-container SDK server (parity with the Claude SessionStart sdk-startup-hook),
-# then runs opencode non-interactively. opencode speaks the OpenAI protocol, so
-# grok-build-0.1 runs natively against api.x.ai/v1 with no shim, while still
-# reaching the RoboCo MCP gateway (roboco-flow / roboco-do / ...) translated into
-# opencode's mcp config.
-set -euo pipefail
-
-SDK_PORT="${ROBOCO_SDK_PORT:-9000}"
-SDK_URL="http://localhost:${SDK_PORT}"
-
-# Generate opencode.json (provider + model + MCP gateway + permissions +
-# instructions). Writes to opencode's global config dir by default.
-# Run from /app so `python -m` resolves the INSTALLED roboco package. Dev/doc/qa
-# agents run at their workspace-clone cwd, which has its own `roboco/` dir on the
-# sys.path front (python -m prepends cwd); on a branch without the grok code that
-# clone lacks roboco.llm.providers and shadows /app → ModuleNotFoundError. The
-# config render has no cwd dependency (writes global, reads ROBOCO_MCP_CONFIG).
-( cd /app && python -m roboco.llm.providers.opencode_config )
-
-# --- SDK server bring-up (Claude-parity) ----------------------------------
-# The flow/do MCP servers POST /verb/attempted here for the per-verb circuit
-# breaker; the budget-feed opencode plugin POSTs /budget/* + /terminal/* here;
-# the post-exit hook below reads /terminal/status and writes the post-mortem.
-# Bare `python` (the baked venv) — NOT `uv run`, which would re-sync the clone's
-# drifted lock and stall (the #179 fix the Claude hook needs `--no-sync` for).
-if ! curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then
- nohup python -m roboco.agent_sdk.server >/tmp/sdk-server.log 2>&1 &
- for _ in 1 2 3 4 5 6 7 8 9 10; do
- if curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then break; fi
- sleep 0.5
- done
-fi
-# Zero the budget/terminal counters at the start of the session.
-curl -sf -m 2 -X POST "${SDK_URL}/budget/reset" >/dev/null 2>&1 || true
-
-# This is a one-shot delivery agent: the SDK budget server above is mandatory.
-# Tell the budget-feed plugin to FAIL CLOSED if that server ever goes
-# unreachable mid-run, so an unenforceable cost cap halts the burn instead of
-# letting it run uncapped. (Interactive serve images set no such flag.)
-export ROBOCO_BUDGET_ENFORCE=1
-
-# Prompt-injection guard (parity with the Claude UserPromptSubmit hook): the
-# task prompt is DATA, not instructions — refuse a poisoned one before it
-# reaches the model. Same patterns as docker/scripts/user-prompt-hook.sh.
-if ! python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}"; then
- echo "Refusing to run: task prompt matched a prompt-injection pattern." >&2
- exit 1
-fi
-
-# Reasoning effort: GrokProvider sets ROBOCO_GROK_VARIANT per role (e.g.
-# "minimal" for coordination/docs roles to cut reasoning cost). Absent =
-# opencode default (full reasoning).
-variant_arg=()
-if [ -n "${ROBOCO_GROK_VARIANT:-}" ]; then
- variant_arg=(--variant "$ROBOCO_GROK_VARIANT")
-fi
-
-# Run the agent. The prompt comes from an env var (never an untrusted argv
-# positional); `--` separates it from flags so a prompt starting with `--`
-# cannot be parsed as CLI options. `< /dev/null` is REQUIRED: without a closed
-# stdin, `opencode run` hangs after init in a headless / no-TTY environment.
-#
-# We do NOT `exec`: the script must regain control after opencode exits to run
-# the post-mortem + silent-exit substitute below (the Claude SessionEnd / Stop
-# hooks have no opencode equivalent, so the boundary handles them). `set +e`
-# around the run so a non-zero opencode exit doesn't abort before the post-run
-# hooks; tee captures the output for rate-limit detection and PIPESTATUS
-# preserves opencode's real exit code through the pipe.
-RUN_LOG="/tmp/opencode-run.log"
-set +e
-opencode run \
- --model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \
- "${variant_arg[@]}" \
- -- "${ROBOCO_INITIAL_PROMPT:-}" < /dev/null 2>&1 | tee "$RUN_LOG"
-run_rc=${PIPESTATUS[0]}
-set -e
-
-# --- Rate-limit detection (B4) ---------------------------------------------
-# A 429 from xAI ends the one-shot run without the agent ever calling a terminal
-# verb. Detect it from the run output and exit 75 (EX_TEMPFAIL) so the
-# orchestrator PARKS the grok provider instead of the dispatcher re-spawning the
-# same task every tick (429 -> exit -> respawn -> 429, a cost/token loop). A
-# rate-limited task is NOT substituted — it must be retried once the limit lifts.
-RATE_LIMITED=0
-if grep -qiE '(\b429\b|too many requests|rate.?limit|quota exceeded|rate_limit_exceeded)' \
- "$RUN_LOG" 2>/dev/null; then
- RATE_LIMITED=1
-fi
-
-# --- Post-mortem (Claude SessionEnd parity) — always -----------------------
-terminal=$(curl -sf -m 2 "${SDK_URL}/terminal/status" 2>/dev/null || echo "")
-last_tool="null"
-had_terminal="false"
-if [ -n "$terminal" ]; then
- last_tool=$(echo "$terminal" | jq -r '.last_tool // "null"' 2>/dev/null || echo "null")
- had_terminal=$(echo "$terminal" | jq -r '.had_terminal_recently // false' 2>/dev/null || echo "false")
-fi
-
-curl -sf -m 3 -X POST "${SDK_URL}/journal/post_mortem" \
- -H "Content-Type: application/json" \
- -d "{\"terminal_tool\":\"${last_tool}\",\"reason\":\"session_end\"}" \
- >/dev/null 2>&1 || true
-
-if [ "$RATE_LIMITED" = "1" ]; then
- echo "[grok] xAI rate-limited — exiting 75 so the orchestrator parks the" \
- "provider; the task is retried when the limit lifts (not substituted)." >&2
- exit 75
-fi
-
-# --- Silent-exit substitute (Claude Stop parity) ---------------------------
-# Only when NOT rate-limited: if the agent exited WITHOUT a terminal verb
-# (i_am_idle / i_am_done / pass / fail / ...), auto-substitute the task so it is
-# not left stuck in claimed/in_progress for a human to hand-unstick.
-if [ "$had_terminal" != "true" ]; then
- curl -sf -m 3 -X POST "${SDK_URL}/terminal/force_substitute" >/dev/null 2>&1 || true
- echo "[grok] exited without a terminal verb (last tool: ${last_tool}) — auto-substituted." >&2
-fi
-
-exit "$run_rc"
diff --git a/docker/scripts/grok-cli-agent-entrypoint.sh b/docker/scripts/grok-cli-agent-entrypoint.sh
index 36c1ea6e..33ee3ddd 100755
--- a/docker/scripts/grok-cli-agent-entrypoint.sh
+++ b/docker/scripts/grok-cli-agent-entrypoint.sh
@@ -36,16 +36,14 @@ fi
RUN_LOG="/tmp/grok-run.json"
ERR_LOG="/tmp/grok-run.err"
WORKSPACE="${ROBOCO_WORKSPACE:-$PWD}"
-# A fixed session id (set by the provider) makes the run's session store
-# locatable for usage capture below; absent, grok generates its own.
-SESSION_ARGS=()
-[ -n "${ROBOCO_AGENT_SESSION_ID:-}" ] && SESSION_ARGS=(-s "${ROBOCO_AGENT_SESSION_ID}")
+# NOTE: grok generates its own session id and ignores a requested one (`-s` does
+# not pin it), so we do NOT pass a session id in; usage capture below reads the
+# real id back out of the JSON run log instead.
set +e
grok -p "${ROBOCO_INITIAL_PROMPT:-}" \
-m "${ROBOCO_AGENT_MODEL:-grok-build}" \
--cwd "$WORKSPACE" \
--output-format json \
- "${SESSION_ARGS[@]}" \
"${GROK_ARGS[@]}" \
< /dev/null > "$RUN_LOG" 2> "$ERR_LOG"
run_rc=$?
@@ -54,18 +52,19 @@ set -e
cat "$RUN_LOG"
[ -s "$ERR_LOG" ] && cat "$ERR_LOG" >&2
-# Capture token usage from the grok session store (~/.grok/sessions). The
-# orchestrator reads the written usage file back at finalize — the grok analogue
-# of the Claude transcript. Best-effort; never fails the run. Run from /app for
-# the same module-resolution reason as the render above.
-( cd /app && ROBOCO_GROK_RUN_CWD="$WORKSPACE" \
+# Capture token usage from the grok session store (~/.grok/sessions). The reader
+# reads the run's real session id out of $ROBOCO_GROK_RUN_LOG, locates the store,
+# and writes a usage.json the orchestrator reads back at finalize — the grok
+# analogue of the Claude transcript. Best-effort; never fails the run. Run from
+# /app for the same module-resolution reason as the render above.
+( cd /app && ROBOCO_GROK_RUN_CWD="$WORKSPACE" ROBOCO_GROK_RUN_LOG="$RUN_LOG" \
python -m roboco.llm.providers.grok_cli_usage ) || true
-# Rate-limit detection (parity with the opencode B4 path): an xAI 429 / quota
-# error ends the run without a terminal verb. Detect it from the run output and
-# exit 75 (EX_TEMPFAIL) so the orchestrator PARKS the grok provider instead of
-# the dispatcher respawning the same task every tick (429 -> exit -> respawn, a
-# token loop). A rate-limited task is retried once the limit lifts, not dropped.
+# Rate-limit detection: an xAI 429 / quota error ends the run without a terminal
+# verb. Detect it from the run output and exit 75 (EX_TEMPFAIL) so the
+# orchestrator PARKS the grok provider instead of the dispatcher respawning the
+# same task every tick (429 -> exit -> respawn, a token loop). A rate-limited task
+# is retried once the limit lifts, not dropped.
if grep -qiE '(\b429\b|rate.?limit|too many requests|quota|insufficient_quota)' \
"$RUN_LOG" "$ERR_LOG" 2>/dev/null; then
echo "[grok] rate-limited — exiting 75 so the orchestrator parks the provider;" \
diff --git a/roboco/agent_sdk/grok_cli_session.py b/roboco/agent_sdk/grok_cli_session.py
new file mode 100644
index 00000000..c2fcfdaf
--- /dev/null
+++ b/roboco/agent_sdk/grok_cli_session.py
@@ -0,0 +1,256 @@
+"""Grok interactive session over the official ``grok`` CLI — the IntakeSession seam.
+
+The Claude interactive roles (intake/secretary) run a held-open ``ClaudeSDKClient``.
+Grok runs the same conversation on xAI's official ``grok`` CLI: there is no
+long-lived server, so each human turn is one headless ``grok -p`` invocation.
+Conversation context persists by **resuming the same grok session id** — turn 1
+lets grok generate an id (read back from the terminal ``end`` event), and every
+later turn passes ``-r `` so grok reloads the prior transcript. The CLI's
+``--output-format streaming-json`` events (``{type: thought|text|end}``) map to
+the same :class:`StreamChunk` kinds the panel already renders, so the existing
+``IntakeDriver`` loop / ``MessageSource`` / ``EventSink`` / relay are reused
+unchanged — only the ``SessionFactory`` differs.
+
+Verified live on the maintainer's machine (grok 0.2.56):
+ * ``grok -p "" --output-format json`` returns ``{text, sessionId, ...}``;
+ * ``grok -p "" -r `` reloads the conversation (a fact set on
+ turn 1 is recalled on turn 2 under the same session id);
+ * streaming-json emits ``{type:thought,data}`` / ``{type:text,data}`` deltas and
+ a final ``{type:end, sessionId, stopReason}``; tool calls do NOT surface as
+ stream events (so the intake draft is delivered by the propose_draft MCP tool
+ POSTing to the relay, not intercepted here).
+
+Token usage is captured after every turn: the chat reuses one session id, so the
+session store's cumulative total is the running whole-chat usage and the last
+write to ``usage.json`` wins (the orchestrator reads it back at reap).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+import structlog
+
+from roboco.agent_sdk.intake_driver import StreamChunk, _extract_draft
+from roboco.agents_config import get_agent_role
+from roboco.llm.providers.grok_cli_config import grok_cli_args_for_role
+from roboco.llm.providers.grok_cli_usage import capture_session_usage
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncIterator
+
+logger = structlog.get_logger()
+
+_DEFAULT_MODEL = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build")
+# A rate-limit / quota end to a turn leaves no terminal verb on the one-shot path
+# and must read clearly on the interactive one; detected from the run's stderr.
+_RATE_LIMIT_MARKERS = (
+ "429",
+ "rate limit",
+ "too many requests",
+ "quota",
+ "insufficient_quota",
+)
+
+
+def _parse_event(line: str) -> dict[str, Any] | None:
+ """Parse one streaming-json NDJSON line into an event dict, tolerantly."""
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ return None
+ return event if isinstance(event, dict) else None
+
+
+class _StreamAssembler:
+ """Maps grok streaming-json events to ``StreamChunk``s, holding turn state.
+
+ Pure and synchronous so it is unit-tested without the live binary: feed it
+ parsed event dicts, collect the chunks it returns, then read ``session_id``
+ (for the next turn's ``-r``) and ``saw_end`` (to detect an abnormal turn).
+
+ Reasoning (``thought``) deltas are coalesced into one ``thinking`` chunk
+ (flushed when the answer starts or at turn end) — the panel renders reasoning
+ as a block, like the Claude path. Answer (``text``) deltas stream live, one
+ chunk each, for the live-typing effect.
+ """
+
+ def __init__(self) -> None:
+ self._thinking: list[str] = []
+ self._text: list[str] = []
+ self.session_id: str | None = None
+ self.stop_reason: str | None = None
+ self.saw_end: bool = False
+
+ def _flush_thinking(self) -> list[StreamChunk]:
+ if not self._thinking:
+ return []
+ text = "".join(self._thinking)
+ self._thinking = []
+ return [StreamChunk(kind="thinking", text=text)] if text else []
+
+ def feed(self, event: dict[str, Any]) -> list[StreamChunk]:
+ """Return the chunks to emit for one event (may be empty)."""
+ etype = str(event.get("type", ""))
+ if etype == "thought":
+ self._thinking.append(str(event.get("data", "")))
+ return []
+ if etype == "text":
+ out = self._flush_thinking()
+ piece = str(event.get("data", ""))
+ if piece:
+ self._text.append(piece)
+ out.append(StreamChunk(kind="text", text=piece))
+ return out
+ if etype == "end":
+ return self._finish(event)
+ return [] # unknown event types ignored (tolerant)
+
+ def _finish(self, event: dict[str, Any]) -> list[StreamChunk]:
+ out = self._flush_thinking()
+ sid = event.get("sessionId") or event.get("session_id")
+ if isinstance(sid, str) and sid:
+ self.session_id = sid
+ self.stop_reason = str(event.get("stopReason") or "") or None
+ self.saw_end = True
+ # Draft fallback only: the canonical draft path is the propose_draft MCP
+ # tool POSTing straight to the relay (tool calls do not surface as stream
+ # events), but if the agent typed a fenced ```roboco-draft``` block we
+ # still surface it.
+ draft = _extract_draft("".join(self._text))
+ if draft is not None:
+ out.append(StreamChunk(kind="draft", data=draft))
+ out.append(
+ StreamChunk(
+ kind="turn_end",
+ data={"session_id": self.session_id, "stop_reason": self.stop_reason},
+ )
+ )
+ return out
+
+
+def _classify_failure(returncode: int | None, stderr: str) -> str:
+ """A human-readable error for a turn that ended without an ``end`` event."""
+ blob = stderr.strip()
+ if any(marker in blob.lower() for marker in _RATE_LIMIT_MARKERS):
+ return (
+ "Grok is rate-limited right now (the SuperGrok quota is exhausted); "
+ "please wait a moment and send your message again."
+ )
+ detail = blob.splitlines()[-1] if blob else f"exit code {returncode}"
+ return f"The Grok turn ended unexpectedly ({detail}). Please try again."
+
+
+class GrokCliSession: # pragma: no cover - needs the live grok binary
+ """``IntakeSession`` backed by per-turn headless ``grok -p`` invocations.
+
+ Async context manager with no held-open process: ``__aenter__`` returns self,
+ ``__aexit__`` is a no-op (each turn owns its own subprocess). ``send`` runs one
+ turn, resuming the captured grok session id so conversation context persists.
+ """
+
+ def __init__(
+ self,
+ *,
+ cwd: str,
+ agent_id: str,
+ model: str = _DEFAULT_MODEL,
+ usage_file: str | None = None,
+ extra_args: list[str] | None = None,
+ ) -> None:
+ self._cwd = cwd
+ self._agent_id = agent_id
+ self._model = model
+ self._usage_file = usage_file or os.environ.get("ROBOCO_GROK_USAGE_FILE")
+ # The secretary's ROBOCO_AGENT_ID is its UUID (not a slug), so resolve the
+ # role from the id when possible, else the container's ROBOCO_AGENT_ROLE.
+ role = get_agent_role(agent_id) or os.environ.get("ROBOCO_AGENT_ROLE", "")
+ self._role_args = grok_cli_args_for_role(role)
+ self._extra_args = list(extra_args or [])
+ self._session_id: str | None = None
+
+ async def __aenter__(self) -> GrokCliSession:
+ return self
+
+ async def __aexit__(self, *exc: object) -> None:
+ return None
+
+ def _build_argv(self, text: str) -> list[str]:
+ argv = [
+ "grok",
+ "-p",
+ text,
+ "-m",
+ self._model,
+ "--cwd",
+ self._cwd,
+ "--output-format",
+ "streaming-json",
+ *self._role_args,
+ *self._extra_args,
+ ]
+ if self._session_id:
+ argv += ["-r", self._session_id]
+ return argv
+
+ async def send(self, text: str) -> AsyncIterator[StreamChunk]:
+ """Run one turn (one ``grok -p`` invocation) and yield its chunks.
+
+ A turn always ends with a ``turn_end`` chunk; a failure (spawn error,
+ non-zero exit, or no ``end`` event) yields an ``error`` chunk first so the
+ panel never renders a blank turn.
+ """
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *self._build_argv(text),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ except OSError as exc:
+ logger.error("grok turn could not start", error=str(exc))
+ yield StreamChunk(kind="error", text=f"Could not start Grok: {exc}")
+ yield StreamChunk(kind="turn_end", data={})
+ return
+
+ assembler = _StreamAssembler()
+ assert proc.stdout is not None
+ async for raw in proc.stdout:
+ event = _parse_event(raw.decode("utf-8", "replace").strip())
+ if event is None:
+ continue
+ for chunk in assembler.feed(event):
+ yield chunk
+
+ stderr_bytes = await proc.stderr.read() if proc.stderr else b""
+ stderr = stderr_bytes.decode("utf-8", "replace")
+ await proc.wait()
+
+ if assembler.session_id:
+ self._session_id = assembler.session_id
+ self._capture_usage()
+
+ if not assembler.saw_end:
+ logger.error(
+ "grok turn ended without a result",
+ returncode=proc.returncode,
+ stderr=stderr.strip()[:500],
+ )
+ yield StreamChunk(
+ kind="error", text=_classify_failure(proc.returncode, stderr)
+ )
+ yield StreamChunk(kind="turn_end", data={})
+
+ def _capture_usage(self) -> None:
+ """Best-effort: rewrite usage.json with the chat's cumulative total."""
+ if not (self._usage_file and self._session_id):
+ return
+ capture_session_usage(
+ cwd=self._cwd,
+ session_id=self._session_id,
+ model=self._model,
+ out_path=Path(self._usage_file),
+ )
diff --git a/roboco/agent_sdk/grok_intake_main.py b/roboco/agent_sdk/grok_intake_main.py
index 68546e75..71b9ae8a 100644
--- a/roboco/agent_sdk/grok_intake_main.py
+++ b/roboco/agent_sdk/grok_intake_main.py
@@ -1,17 +1,14 @@
-"""Container entrypoint for the GROK intake (prompter) agent — opencode serve.
+"""Container entrypoint for the GROK intake (prompter) agent — grok CLI.
The Grok analogue of ``intake_main``: the same in-container ``POST /turn``
receiver and the same relay sink to ``/api/prompter/live/{id}/events``, but the
-held-open session is an :class:`OpencodeServeSession` (``opencode serve``)
-instead of a ``ClaudeSDKClient``. ``opencode.json`` (model + system prompt) is
-rendered first. Intake is a human-only interviewer (no gateway verbs); its one
-action tool, ``propose_draft``, is registered by the ``intake-tools.js`` opencode
-plugin (baked into the grok-prompter image's plugin auto-discovery dir). Because
-opencode's synchronous serve reply does NOT carry tool-call parts, that plugin
-POSTs the draft straight to the prompter-live relay (the same
-``/api/prompter/live/{id}/events`` endpoint) so the panel renders the draft card.
-The ``IntakeDriver`` loop, message source, and relay are reused unchanged — only
-the ``SessionFactory`` differs.
+held-open session is a :class:`GrokCliSession` (per-turn headless ``grok -p``,
+resuming one session id) instead of a ``ClaudeSDKClient``. ``~/.grok/config.toml``
+is rendered first to wire the intake agent's one action tool, ``propose_draft``,
+as the ``roboco-intake`` MCP server. Intake is a human-only interviewer with no
+gateway verbs; its only MCP server is ``roboco-intake``. The ``IntakeDriver``
+loop, message source, and relay are reused unchanged — only the
+``SessionFactory`` differs.
"""
from __future__ import annotations
@@ -24,14 +21,14 @@ from typing import TYPE_CHECKING
import httpx
import structlog
+from roboco.agent_sdk.grok_cli_session import GrokCliSession
from roboco.agent_sdk.intake_driver import IntakeDriver
from roboco.agent_sdk.intake_main import (
build_receiver,
make_message_source,
make_relay_sink,
)
-from roboco.agent_sdk.opencode_session import OpencodeServeSession, serve_port
-from roboco.llm.providers import opencode_config
+from roboco.llm.providers.grok_cli_config import GROK_CONFIG_PATH, render_config_toml
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -41,24 +38,63 @@ logger = structlog.get_logger()
_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here
-async def main() -> None: # pragma: no cover - needs the live container + opencode
- """Render opencode.json, then run the receiver + driver for the chat's life."""
- import uvicorn
+def _render_grok_config(base_url: str, session_id: str) -> None:
+ """Write ``~/.grok/config.toml`` wiring the ``roboco-intake`` MCP server.
- # Render opencode.json (provider/model/MCP gateway/instructions) from the
- # spawn env so `opencode serve` is gateway-wired before it starts.
- opencode_config.main()
+ ``uv run --directory /app`` pins both the project env (the baked
+ ``/app/.venv``) and the working directory to ``/app`` so ``-m
+ roboco.mcp.intake_server`` resolves the INSTALLED package, never a workspace
+ clone that might shadow it (the ModuleNotFound lesson).
+ """
+ mcp_servers = {
+ "roboco-intake": {
+ "command": "uv",
+ "args": [
+ "run",
+ "--directory",
+ "/app",
+ "--no-sync",
+ "python",
+ "-m",
+ "roboco.mcp.intake_server",
+ ],
+ "env": {
+ "ROBOCO_API_URL": base_url,
+ "ROBOCO_PROMPTER_SESSION_ID": session_id,
+ "UV_PROJECT_ENVIRONMENT": "/app/.venv",
+ },
+ }
+ }
+ GROK_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
+ GROK_CONFIG_PATH.write_text(
+ render_config_toml({"mcpServers": mcp_servers}), encoding="utf-8"
+ )
+
+
+async def main() -> None: # pragma: no cover - needs the live container + grok
+ """Render config.toml, then run the receiver + driver for the chat's life."""
+ import uvicorn
session_id = os.environ["ROBOCO_PROMPTER_SESSION_ID"]
base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace")
+ _render_grok_config(base_url, session_id)
+
queue: asyncio.Queue[str | None] = asyncio.Queue()
client = httpx.AsyncClient(timeout=30.0)
@asynccontextmanager
- async def session_factory() -> AsyncIterator[OpencodeServeSession]:
- async with OpencodeServeSession(port=serve_port(), cwd=cwd) as session:
+ async def session_factory() -> AsyncIterator[GrokCliSession]:
+ async with GrokCliSession(
+ cwd=cwd,
+ agent_id=os.environ.get("ROBOCO_AGENT_ID", ""),
+ model=os.environ.get("ROBOCO_AGENT_MODEL", "grok-build"),
+ usage_file=os.environ.get("ROBOCO_GROK_USAGE_FILE"),
+ # Intake reads sibling product repos that sit outside its cwd under
+ # the mounted workspaces tree — keep those reads allowed.
+ extra_args=["--allow", "Read(/data/workspaces/**)"],
+ ) as session:
yield session
driver = IntakeDriver(
diff --git a/roboco/agent_sdk/grok_secretary_main.py b/roboco/agent_sdk/grok_secretary_main.py
index 6b6349da..a4576fe5 100644
--- a/roboco/agent_sdk/grok_secretary_main.py
+++ b/roboco/agent_sdk/grok_secretary_main.py
@@ -1,14 +1,13 @@
-"""Container entrypoint for the GROK Secretary agent — opencode serve.
+"""Container entrypoint for the GROK Secretary agent — grok CLI.
The Grok analogue of ``secretary_main``: the same in-container ``POST /turn``
receiver and the same relay sink to ``/api/secretary/live/{id}/events``, but the
-held-open session is an :class:`OpencodeServeSession` (``opencode serve``) rather
-than a ``ClaudeSDKClient``. ``opencode.json`` (xAI provider + system prompt) is
-rendered first. The Secretary's CEO-authority tools (read_company_state /
-read_task / submit_directive) are registered as opencode tools by the
-``secretary-tools.js`` plugin (baked into the grok-secretary image and wired in
-via ``ROBOCO_OPENCODE_EXTRA_PLUGINS``); they call ``/api/secretary/*`` with the
-container's HMAC agent token, the same auth the one-shot Grok path uses.
+held-open session is a :class:`GrokCliSession` (per-turn headless ``grok -p``,
+resuming one session id) rather than a ``ClaudeSDKClient``. ``~/.grok/config.toml``
+is rendered first to wire the Secretary's CEO-authority tools (read_company_state
+/ read_task / submit_directive) as the ``roboco-secretary`` MCP server, which
+calls ``/api/secretary/*`` with the container's HMAC agent token — the same auth
+the one-shot Grok path uses.
"""
from __future__ import annotations
@@ -21,11 +20,11 @@ from typing import TYPE_CHECKING
import httpx
import structlog
+from roboco.agent_sdk.grok_cli_session import GrokCliSession
from roboco.agent_sdk.intake_driver import IntakeDriver
from roboco.agent_sdk.intake_main import build_receiver, make_message_source
-from roboco.agent_sdk.opencode_session import OpencodeServeSession, serve_port
from roboco.agent_sdk.secretary_main import make_relay_sink
-from roboco.llm.providers import opencode_config
+from roboco.llm.providers.grok_cli_config import GROK_CONFIG_PATH, render_config_toml
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -35,22 +34,62 @@ logger = structlog.get_logger()
_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here
-async def main() -> None: # pragma: no cover - needs the live container + opencode
- """Render opencode.json, then run the receiver + driver for the chat's life."""
- import uvicorn
+def _render_grok_config(base_url: str) -> None:
+ """Write ``~/.grok/config.toml`` wiring the ``roboco-secretary`` MCP server.
- opencode_config.main()
+ ``uv run --directory /app`` pins the project env + working directory to
+ ``/app`` so ``-m roboco.mcp.secretary_server`` resolves the installed package
+ (the ModuleNotFound lesson). The directive tools authenticate from the
+ container's HMAC env, forwarded into the server's env below.
+ """
+ mcp_servers = {
+ "roboco-secretary": {
+ "command": "uv",
+ "args": [
+ "run",
+ "--directory",
+ "/app",
+ "--no-sync",
+ "python",
+ "-m",
+ "roboco.mcp.secretary_server",
+ ],
+ "env": {
+ "ROBOCO_API_URL": base_url,
+ "ROBOCO_AGENT_ID": os.environ.get("ROBOCO_AGENT_ID", ""),
+ "ROBOCO_AGENT_ROLE": os.environ.get("ROBOCO_AGENT_ROLE", "secretary"),
+ "ROBOCO_AGENT_TOKEN": os.environ.get("ROBOCO_AGENT_TOKEN", ""),
+ "UV_PROJECT_ENVIRONMENT": "/app/.venv",
+ },
+ }
+ }
+ GROK_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
+ GROK_CONFIG_PATH.write_text(
+ render_config_toml({"mcpServers": mcp_servers}), encoding="utf-8"
+ )
+
+
+async def main() -> None: # pragma: no cover - needs the live container + grok
+ """Render config.toml, then run the receiver + driver for the chat's life."""
+ import uvicorn
session_id = os.environ["ROBOCO_SECRETARY_SESSION_ID"]
base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
cwd = os.environ.get("ROBOCO_WORKSPACE", "/app")
+ _render_grok_config(base_url)
+
queue: asyncio.Queue[str | None] = asyncio.Queue()
client = httpx.AsyncClient(timeout=30.0)
@asynccontextmanager
- async def session_factory() -> AsyncIterator[OpencodeServeSession]:
- async with OpencodeServeSession(port=serve_port(), cwd=cwd) as session:
+ async def session_factory() -> AsyncIterator[GrokCliSession]:
+ async with GrokCliSession(
+ cwd=cwd,
+ agent_id=os.environ.get("ROBOCO_AGENT_ID", ""),
+ model=os.environ.get("ROBOCO_AGENT_MODEL", "grok-build"),
+ usage_file=os.environ.get("ROBOCO_GROK_USAGE_FILE"),
+ ) as session:
yield session
driver = IntakeDriver(
diff --git a/roboco/agent_sdk/intake_driver.py b/roboco/agent_sdk/intake_driver.py
index a1c4b79c..a7cf34b4 100644
--- a/roboco/agent_sdk/intake_driver.py
+++ b/roboco/agent_sdk/intake_driver.py
@@ -263,7 +263,7 @@ class IntakeDriver:
"""
# Prompt-injection guard at the input boundary (our own guard, runtime-
# agnostic): deny a poisoned turn before the model ever sees it. Covers
- # the Grok (opencode) session and the Claude SDK session — the latter
+ # the Grok (grok-CLI) session and the Claude SDK session — the latter
# runs with setting_sources=[] and so never loads the bash UserPromptSubmit
# hook, so this is the only injection guard either interactive path has.
injection = detect_injection(text)
diff --git a/roboco/agent_sdk/opencode_session.py b/roboco/agent_sdk/opencode_session.py
deleted file mode 100644
index 076df56b..00000000
--- a/roboco/agent_sdk/opencode_session.py
+++ /dev/null
@@ -1,284 +0,0 @@
-"""Grok interactive session over ``opencode serve`` — the IntakeSession seam.
-
-The Claude interactive roles (intake/secretary) run a held-open ``ClaudeSDKClient``.
-Grok has no Claude binary, so its interactive runtime is ``opencode serve``: a
-long-lived local opencode HTTP server (the rendered ``opencode.json`` wires the
-xAI provider + the RoboCo MCP gateway + the system prompt as instructions). Each
-human turn is one **synchronous** ``POST /session/:id/message`` ("send and wait"),
-whose ``parts`` are mapped to the same :class:`StreamChunk` kinds the panel
-already renders. Conversation context persists because the opencode session is
-reused across turns. ``OpencodeServeSession`` satisfies the same ``IntakeSession``
-protocol the Claude ``SdkIntakeSession`` does, so the existing ``IntakeDriver``
-loop / ``MessageSource`` / ``EventSink`` / relay are reused unchanged.
-
-Confirmed against opencode's server docs (https://opencode.ai/docs/server):
-``opencode serve`` on 127.0.0.1:, ``POST /session`` -> Session, and the
-synchronous ``POST /session/:id/message`` -> ``{info, parts}``. Using the
-synchronous endpoint avoids the async ``/event`` SSE-bus correlation; the
-trade-off is that a turn's reply renders when it completes rather than as live
-token deltas (a later enhancement, once verifiable against grok-build-0.1).
-
-UNVERIFIED-LIVE: opencode's exact ``Part`` schema is read defensively here — the
-mapping tolerates unknown shapes — and must be confirmed against a live
-``opencode serve`` + grok-build-0.1 run on the NAS.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import contextlib
-import os
-from typing import TYPE_CHECKING, Any
-
-import httpx
-import structlog
-
-from roboco.agent_sdk.intake_driver import (
- StreamChunk,
- _draft_from_tool_input,
- _extract_draft,
- _is_propose_draft,
-)
-
-if TYPE_CHECKING:
- from collections.abc import AsyncIterator
-
-logger = structlog.get_logger()
-
-_DEFAULT_PORT = 4096
-_READY_TIMEOUT_S = 30.0
-_READY_INTERVAL_S = 0.5
-
-
-def _part_to_chunk(
- part: dict[str, Any],
-) -> tuple[StreamChunk | None, str | None, dict[str, Any] | None]:
- """Classify one opencode message part -> (chunk, text_part, draft).
-
- Mirrors ``intake_driver._block_to_chunk`` for the opencode part shape and is
- tolerant of unknown shapes (skipped) since the Part schema may evolve.
- """
- ptype = str(part.get("type", ""))
- if ptype in ("reasoning", "thinking"):
- return StreamChunk(kind="thinking", text=str(part.get("text", ""))), None, None
- if ptype in ("tool", "tool-invocation", "tool_use"):
- name = str(part.get("tool") or part.get("name") or "")
- tool_input = part.get("input") or part.get("args") or {}
- if _is_propose_draft(name):
- return None, None, _draft_from_tool_input(tool_input)
- return (
- StreamChunk(kind="tool_use", tool=name, data={"input": tool_input}),
- None,
- None,
- )
- if ptype == "text":
- return None, str(part.get("text", "")), None
- return None, None, None
-
-
-def _message_error(message: dict[str, Any]) -> str | None:
- """Human-readable text of a turn-level error (``info.error``), else ``None``.
-
- A model/turn failure (bad key, rate limit, model error) is reported by
- opencode in ``info.error`` with an EMPTY ``parts`` list — not as a part — so
- it must be surfaced explicitly or the turn renders blank (the original Claude
- intake bug). Confirmed live: a bad xAI key returns
- ``info.error={"name":"APIError","data":{"message":"Incorrect API key ..."}}``.
- """
- info = message.get("info")
- if not isinstance(info, dict):
- return None
- err = info.get("error")
- if not err:
- return None
- if isinstance(err, dict):
- data = err.get("data")
- if isinstance(data, dict) and data.get("message"):
- return str(data["message"])
- if err.get("name"):
- return str(err["name"])
- return str(err)
-
-
-def normalize_opencode_message(message: dict[str, Any]) -> list[StreamChunk]:
- """Map an opencode message reply (``{info, parts}``) to panel chunks.
-
- Unlike the Claude path (which streams text deltas live and so drops the final
- TextBlock to avoid double-render), the synchronous opencode reply carries the
- text only here, so text parts ARE emitted. A turn-level ``info.error`` is
- surfaced as an ``error`` chunk so a failed turn is never silently blank.
-
- Draft note: opencode's synchronous serve reply does NOT include tool-call
- parts, so the intake draft is delivered by the ``intake-tools.js``
- propose_draft tool POSTing to the prompter-live relay directly (not from
- here). The ``propose_draft`` tool-part and fenced ```roboco-draft``` handling
- below is a tolerant fallback for any opencode version that DOES surface the
- tool call in parts; it is normally a no-op on the serve path.
- """
- parts = message.get("parts") or []
- chunks: list[StreamChunk] = []
- text_parts: list[str] = []
- draft: dict[str, Any] | None = None
- for part in parts:
- chunk, text_part, block_draft = _part_to_chunk(part)
- if chunk is not None:
- chunks.append(chunk)
- if text_part is not None:
- text_parts.append(text_part)
- chunks.append(StreamChunk(kind="text", text=text_part))
- draft = draft or block_draft
- draft = draft or _extract_draft("".join(text_parts))
- if draft is not None:
- chunks.append(StreamChunk(kind="draft", data=draft))
- error = _message_error(message)
- if error:
- chunks.append(StreamChunk(kind="error", text=error))
- chunks.append(StreamChunk(kind="turn_end", data={}))
- return chunks
-
-
-class OpencodeServeSession:
- """``IntakeSession`` backed by a long-lived ``opencode serve`` process.
-
- Async context manager: ``__aenter__`` launches ``opencode serve`` and opens
- one session; ``__aexit__`` tears the server down. ``send`` runs one turn via
- the synchronous message endpoint and yields normalized chunks. The opencode
- session id is reused across turns so conversation context persists in the
- server (the held-open analogue of the Claude SDK client).
- """
-
- def __init__(
- self,
- *,
- port: int = _DEFAULT_PORT,
- cwd: str | None = None,
- ) -> None:
- self._port = port
- self._cwd = cwd
- self._proc: asyncio.subprocess.Process | None = None
- self._client: httpx.AsyncClient | None = None
- self._session_id: str | None = None
-
- @property
- def _base(self) -> str:
- return f"http://127.0.0.1:{self._port}"
-
- async def __aenter__(self) -> OpencodeServeSession:
- self._proc = await asyncio.create_subprocess_exec(
- "opencode",
- "serve",
- "--port",
- str(self._port),
- "--hostname",
- "127.0.0.1",
- cwd=self._cwd,
- )
- # A generous per-turn timeout (grok-build-0.1 reasons before replying);
- # a short connect timeout so readiness polling fails fast and retries.
- self._client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=5.0))
- self._session_id = await self._open_session()
- logger.info("opencode serve session opened", session=self._session_id)
- return self
-
- async def __aexit__(self, *exc: object) -> None:
- if self._client is not None:
- await self._client.aclose()
- if self._proc is not None and self._proc.returncode is None:
- self._proc.terminate()
- with contextlib.suppress(Exception):
- await asyncio.wait_for(self._proc.wait(), timeout=10.0)
-
- async def _open_session(self) -> str:
- """Create an opencode session, retrying until the server is ready."""
- assert self._client is not None
- last_error: str = "no response"
- attempts = max(1, int(_READY_TIMEOUT_S / _READY_INTERVAL_S))
- for _ in range(attempts):
- try:
- resp = await self._client.post(f"{self._base}/session", json={})
- if resp.is_success:
- sid = _extract_session_id(resp.json())
- if sid:
- return sid
- last_error = f"HTTP {resp.status_code}"
- except Exception as exc: # server not up yet / transient
- last_error = str(exc)
- await asyncio.sleep(_READY_INTERVAL_S)
- raise RuntimeError(f"opencode serve never became ready: {last_error}")
-
- async def send(self, text: str) -> AsyncIterator[StreamChunk]:
- """Run one turn (synchronous message) and yield its normalized chunks."""
- if self._client is None or self._session_id is None:
- raise RuntimeError("OpencodeServeSession used outside its context")
- # If the serve subprocess has died, the session is gone — every turn
- # would otherwise fail with an opaque httpx connection error while the
- # container lingers as a zombie. Surface it clearly (the panel shows a
- # real message) and end the turn; the idle watchdog / a human reap tears
- # the container down.
- if self._proc is not None and self._proc.returncode is not None:
- logger.error("opencode serve exited", returncode=self._proc.returncode)
- yield StreamChunk(
- kind="error",
- text=(
- f"opencode serve exited (rc={self._proc.returncode}); this "
- "chat session ended — please start a new chat."
- ),
- )
- yield StreamChunk(kind="turn_end", data={})
- return
- body: dict[str, Any] = {"parts": [{"type": "text", "text": text}]}
- # Per-role reasoning effort: the orchestrator sets ROBOCO_GROK_VARIANT on
- # the container; the serve message endpoint accepts a `variant` field
- # (confirmed against the live opencode OpenAPI), the same lever the
- # one-shot path drives via `opencode run --variant`.
- variant = _variant()
- if variant:
- body["variant"] = variant
- try:
- resp = await self._client.post(
- f"{self._base}/session/{self._session_id}/message",
- json=body,
- )
- resp.raise_for_status()
- message = resp.json()
- except Exception as exc:
- logger.error("opencode message turn failed", error=str(exc))
- yield StreamChunk(kind="error", text=str(exc))
- return
- for chunk in normalize_opencode_message(message):
- yield chunk
-
-
-def _extract_session_id(payload: Any) -> str | None:
- """Pull the session id out of opencode's POST /session response, tolerantly."""
- if not isinstance(payload, dict):
- return None
- for key in ("id", "sessionID", "session_id"):
- value = payload.get(key)
- if isinstance(value, str) and value:
- return value
- info = payload.get("info")
- if isinstance(info, dict):
- ident = info.get("id")
- if isinstance(ident, str) and ident:
- return ident
- return None
-
-
-def serve_port() -> int:
- """The opencode serve port (override with ROBOCO_OPENCODE_SERVE_PORT)."""
- raw = os.environ.get("ROBOCO_OPENCODE_SERVE_PORT", "").strip()
- if raw.isdigit() and int(raw) > 0:
- return int(raw)
- return _DEFAULT_PORT
-
-
-def _variant() -> str | None:
- """The opencode reasoning variant to apply per turn (ROBOCO_GROK_VARIANT).
-
- Set by the orchestrator from the per-role reasoning-effort policy (the same
- value the one-shot path passes to ``opencode run --variant``); unset = the
- model's default (full) reasoning.
- """
- raw = os.environ.get("ROBOCO_GROK_VARIANT", "").strip()
- return raw or None
diff --git a/roboco/agent_sdk/prompt_guard.py b/roboco/agent_sdk/prompt_guard.py
index 8c2e14f7..d1ff6c2a 100644
--- a/roboco/agent_sdk/prompt_guard.py
+++ b/roboco/agent_sdk/prompt_guard.py
@@ -1,15 +1,14 @@
"""Prompt-injection guard — shared detector for incoming agent turns.
RoboCo's prompt-injection guard is its OWN hook (``docker/scripts/user-prompt-hook.sh``,
-a Claude Code UserPromptSubmit hook), not a runtime built-in. opencode has no
-blocking pre-prompt hook, but it doesn't need one: the guard belongs at RoboCo's
-input boundary, in our own code, regardless of runtime. This ports the deny
-patterns to reusable Python so the same guard applies to:
+a Claude Code UserPromptSubmit hook), not a runtime built-in. The guard belongs
+at RoboCo's input boundary, in our own code, regardless of runtime. This ports
+the deny patterns to reusable Python so the same guard applies to:
* interactive sessions (intake / secretary) — the ``IntakeDriver`` scans each
turn before sending it to the model, covering BOTH Claude (whose SDK session
runs with ``setting_sources=[]`` and so never loads the bash hook) and Grok
- (opencode, no blocking pre-prompt hook);
+ (the grok CLI, scanned at the same boundary);
* one-shot Grok agents — the grok entrypoint scans ``ROBOCO_INITIAL_PROMPT``.
Content delivered to an agent (an A2A skill request, a PM's task description, an
diff --git a/roboco/billing/pricing.py b/roboco/billing/pricing.py
index 50424555..0641d3b6 100644
--- a/roboco/billing/pricing.py
+++ b/roboco/billing/pricing.py
@@ -115,7 +115,7 @@ def calculate_cost(
Reasoning/thinking tokens that a provider reports *separately* from
output (e.g. xAI grok-build-*) are billed at the output rate by the
caller folding them into ``tokens_output`` (see
- ``opencode_usage.cost_for_session``).
+ ``grok_cli_usage.usage_and_cost``).
Returns:
Estimated cost in USD as a float. Returns 0.0 for unpriced models
diff --git a/roboco/config.py b/roboco/config.py
index 2eb2d30f..3bf721a5 100644
--- a/roboco/config.py
+++ b/roboco/config.py
@@ -662,12 +662,12 @@ class Settings(BaseSettings):
"override via ROBOCO_STALE_CLAIM_REAP_SECONDS"
),
)
- # A GROK (opencode) agent that wedges — an idle model call / stream with no
- # gateway verb — is ACTIVE-yet-silent, so the heartbeat reaper's live-
- # container skip would shield its task forever (opencode emits no SDK budget
- # signal and advances no heartbeat while parked, unlike a Claude agent that
- # at least reports). After this longer window the orchestrator kills + evicts
- # the container so the reaper releases the task. Longer than
+ # A GROK agent that wedges — an idle model call / stream with no gateway
+ # verb — is ACTIVE-yet-silent, so the heartbeat reaper's live-container skip
+ # would shield its task forever (the grok CLI emits no SDK budget signal and
+ # advances no heartbeat while parked, unlike a Claude agent that at least
+ # reports). After this longer window the orchestrator kills + evicts the
+ # container so the reaper releases the task. Longer than
# stale_claim_reap_seconds so only a truly-dead run trips it, never a
# slow-but-working agent.
grok_idle_kill_seconds: int = Field(
@@ -679,10 +679,10 @@ class Settings(BaseSettings):
),
)
# Budget kill-switch parity for GROK. Claude Code's per-agent token-budget
- # hook fires against the SDK :9000 server; opencode exposes no usage hook to
- # a plugin, so the orchestrator enforces the cap by reading each live GROK
- # container's cumulative cost from its opencode store and killing it when it
- # crosses this ceiling (also catches runaway-loop token burn). USD; 0 = off.
+ # hook fires against the SDK :9000 server; the grok CLI exposes no live usage
+ # hook, so the orchestrator enforces the cap by reading each live GROK
+ # container's captured cost from its usage.json and killing it when it crosses
+ # this ceiling (also catches runaway-loop token burn). USD; 0 = off.
grok_max_cost_usd: float = Field(
default=0.0,
ge=0,
diff --git a/roboco/llm/providers/grok.py b/roboco/llm/providers/grok.py
index 353c9537..fc4f0efc 100644
--- a/roboco/llm/providers/grok.py
+++ b/roboco/llm/providers/grok.py
@@ -31,7 +31,6 @@ import os
from pathlib import Path
from typing import TYPE_CHECKING, Protocol
-from roboco.agents_config import get_agent_role
from roboco.llm.providers._docker import container_running, stop_container
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
@@ -50,9 +49,7 @@ _GROK_CLI_MODEL = os.environ.get("ROBOCO_GROK_CLI_MODEL", "grok-build")
# Host directory holding the SuperGrok auth (from ``grok login``). Mounted into
# the agent's ``~/.grok`` like the Claude path mounts ``~/.claude``. Override for
# docker-in-docker / NAS deploys (the orchestrator's home is not the host's).
-GROK_AUTH_HOST_PATH = os.environ.get(
- "ROBOCO_HOST_GROK_DIR", str(Path.home() / ".grok")
-)
+GROK_AUTH_HOST_PATH = os.environ.get("ROBOCO_HOST_GROK_DIR", str(Path.home() / ".grok"))
# In-container paths.
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
@@ -68,44 +65,6 @@ def _container_name(agent_id: str) -> str:
return f"roboco-agent-{agent_id}"
-# --- interactive (opencode-serve) shim — PENDING conversion to the grok CLI ---
-# The intake / secretary roles still run on the opencode-serve interactive path,
-# which needs the opencode ``--variant`` reasoning effort ("minimal") — distinct
-# from the one-shot CLI's ``--effort`` ("low") computed in grok_cli_config. Kept
-# here until the interactive path is moved onto the grok CLI too; the orchestrator
-# interactive-spawn methods import it.
-_MINIMAL_REASONING_ROLES = frozenset(
- {
- "cell_pm",
- "main_pm",
- "documenter",
- "product_owner",
- "head_marketing",
- "auditor",
- "prompter",
- "secretary",
- }
-)
-_FULL_REASONING_OVERRIDES = frozenset({"default", "full", "none", ""})
-
-
-def _reasoning_effort_for(agent_id: str) -> str | None:
- """opencode ``--variant`` for the interactive serve path (pending conversion).
-
- Returns ``None`` (opencode default / full reasoning) for code-quality roles;
- ``"minimal"`` for coordination / docs / board roles. A global
- ``ROBOCO_GROK_REASONING_EFFORT`` override wins.
- """
- override = os.environ.get("ROBOCO_GROK_REASONING_EFFORT", "").strip()
- if override:
- return None if override.lower() in _FULL_REASONING_OVERRIDES else override
- return (
- "minimal"
- if (get_agent_role(agent_id) or "") in _MINIMAL_REASONING_ROLES
- else None
- )
-
-
class _GrokHost(Protocol):
"""The orchestrator surface GrokCliProvider reuses for container assembly.
@@ -115,7 +74,7 @@ class _GrokHost(Protocol):
async def _remove_container(self, container_name: str) -> None: ...
- def _ensure_opencode_data_dir(self, agent_id: str) -> None: ...
+ def _ensure_grok_usage_dir(self, agent_id: str) -> None: ...
def _resolve_host_paths(
self, config: AgentConfig, agent_settings_path: Path | None
@@ -156,7 +115,7 @@ class GrokCliProvider(AgentProvider):
await self._host._remove_container(container_name)
# Pre-create the per-agent data dir (world-writable) before the bind
# mount so the non-root agent can write the usage file (else EACCES).
- self._host._ensure_opencode_data_dir(config.agent_id)
+ self._host._ensure_grok_usage_dir(config.agent_id)
# Reuse the orchestrator's mount/auth/git assembly so the agent gets the
# full MCP gateway + identity wiring. Blank the provider routing fields
@@ -208,11 +167,11 @@ class GrokCliProvider(AgentProvider):
def _append_usage_mount(cmd: list[str], hosts: dict[str, str | None]) -> None:
"""Mount the per-agent data dir so the orchestrator reads usage back.
- Reuses the shared per-agent host dir (``hosts["opencode"]``); the
+ Reuses the shared per-agent host dir (``hosts["grok_usage"]``); the
entrypoint writes ``usage.json`` here after the run and the orchestrator
reads it at finalize. Without it a Grok agent finalizes at 0 tokens / $0.
"""
- data_host = hosts.get("opencode")
+ data_host = hosts.get("grok_usage")
if data_host:
cmd.extend(["-v", f"{data_host}:{_GROK_USAGE_DIR_IN_CONTAINER}"])
@@ -239,11 +198,6 @@ class GrokCliProvider(AgentProvider):
f"ROBOCO_GROK_USAGE_FILE={_GROK_USAGE_FILE_IN_CONTAINER}",
]
)
- if config.claude_session_id:
- # A fixed session id (reused as the generic agent session id, as on
- # the Claude path) lets the entrypoint pin `grok -p -s ` so the
- # run's session store is locatable for token-usage capture.
- cmd.extend(["-e", f"ROBOCO_AGENT_SESSION_ID={config.claude_session_id}"])
async def stop(self, instance_id: str, graceful: bool = True) -> None:
await stop_container(instance_id, graceful)
diff --git a/roboco/llm/providers/grok_cli_config.py b/roboco/llm/providers/grok_cli_config.py
index f7881157..5beebae1 100644
--- a/roboco/llm/providers/grok_cli_config.py
+++ b/roboco/llm/providers/grok_cli_config.py
@@ -9,7 +9,7 @@ command. Keeping the translation in importable Python (not a shell heredoc)
makes it unit-testable.
Parity with ``ClaudeCodeProvider``'s per-role permissions, expressed as native
-grok flags instead of an opencode permission block + a JS guard plugin:
+grok flags (built-in tool removal + ``--deny`` rules):
* **subagents** — every role gets ``--disallowed-tools Agent``: no RoboCo agent
spawns the CLI's own subagents (work is driven through the gateway verbs).
@@ -19,7 +19,7 @@ grok flags instead of an opencode permission block + a JS guard plugin:
(``--disallowed-tools run_terminal_cmd``).
* **git mutation** — bash-capable roles keep a shell, but raw git mutation is
denied (``--deny "Bash(git push*)"`` ...): agents commit / push through the
- gateway verbs, never raw git (the opencode secret-scrub git-ops rule, ported).
+ gateway verbs, never raw git.
* **destructive** — ``--deny "Bash(rm -rf*)"`` for every bash-capable role.
* **reasoning** — ``--effort`` by role (``low`` for coordination / docs / board;
full for the code-quality roles). A global ``ROBOCO_GROK_REASONING_EFFORT``
@@ -44,8 +44,7 @@ GROK_CONFIG_PATH = Path.home() / ".grok" / "config.toml"
# The entrypoint reads the computed flags (one token per line) from this file.
GROK_ARGS_PATH = Path(os.environ.get("ROBOCO_GROK_ARGS_FILE", "/tmp/roboco-grok-args"))
-# Hard ceiling on agentic turns (loop guard; replaces the opencode budget-feed
-# loop cap). Operator-tunable.
+# Hard ceiling on agentic turns (loop guard). Operator-tunable.
_DEFAULT_MAX_TURNS = 200
# Roles that request reduced reasoning (grok bills reasoning at the output rate,
@@ -146,13 +145,14 @@ def _effort_for(role: str) -> str | None:
return "low" if role in _MINIMAL_REASONING_ROLES else None
-def grok_cli_args(agent_id: str, *, max_turns: int = _DEFAULT_MAX_TURNS) -> list[str]:
- """The per-role ``grok -p`` flag tokens for an agent (excludes ``-p``/model/cwd).
+def grok_cli_args_for_role(
+ role: str, *, max_turns: int = _DEFAULT_MAX_TURNS
+) -> list[str]:
+ """The per-role ``grok -p`` flag tokens (excludes ``-p``/model/cwd).
Order: tool removal, turn cap, deny rules, then effort. Each token is a
- separate list element so the entrypoint can splice them without shell quoting.
+ separate list element so callers can splice them without shell quoting.
"""
- role = get_agent_role(agent_id) or ""
args: list[str] = ["--disallowed-tools", _disallowed_tools(role)]
args += ["--max-turns", str(max_turns)]
for rule in _deny_rules(role):
@@ -163,6 +163,11 @@ def grok_cli_args(agent_id: str, *, max_turns: int = _DEFAULT_MAX_TURNS) -> list
return args
+def grok_cli_args(agent_id: str, *, max_turns: int = _DEFAULT_MAX_TURNS) -> list[str]:
+ """The per-role grok flags for an agent, resolving its role from the id."""
+ return grok_cli_args_for_role(get_agent_role(agent_id) or "", max_turns=max_turns)
+
+
def _load_mcp_config(path: str) -> dict[str, Any]:
"""Load the mounted mcp-config.json, tolerating a missing / invalid file."""
try:
diff --git a/roboco/llm/providers/grok_cli_usage.py b/roboco/llm/providers/grok_cli_usage.py
index d138b4b0..1edf0d9f 100644
--- a/roboco/llm/providers/grok_cli_usage.py
+++ b/roboco/llm/providers/grok_cli_usage.py
@@ -3,9 +3,9 @@
The grok CLI persists each session under ``~/.grok/sessions//
/updates.jsonl``; every update carries a cumulative
``params._meta.totalTokens``, so the maximum across the file is the session's
-total token count. This is the grok analogue of the Claude Code transcript and
-the old opencode.db — Grok runs on the SuperGrok subscription, but (exactly like
-Claude on Max) we still record per-agent tokens and a notional cost.
+total token count. This is the grok analogue of the Claude Code transcript —
+Grok runs on the SuperGrok subscription, but (exactly like Claude on Max) we
+still record per-agent tokens and a notional cost.
The grok CLI reports a single ``totalTokens`` with no input/output split, so the
notional cost prices the whole total at the output rate (the higher rate —
@@ -13,7 +13,14 @@ conservative, and consistent with reasoning tokens billing at the output rate).
The agent entrypoint runs ``python -m roboco.llm.providers.grok_cli_usage`` after
the run to write a small ``usage.json`` (``{model, total_tokens, cost_usd}``)
-into a per-agent dir the orchestrator reads back at finalize.
+into a per-agent dir the orchestrator reads back at finalize. The interactive
+driver reuses :func:`capture_session_usage` directly after each turn (the chat
+reuses one grok session id, so the cumulative total is the whole-chat usage).
+
+The session id must be grok's REAL one: ``grok -p`` ignores a requested id (the
+``-s`` flag does not pin), so the one-shot entrypoint hands us the run's JSON log
+and we read the generated ``sessionId`` out of it (``ROBOCO_GROK_RUN_LOG``),
+falling back to ``ROBOCO_AGENT_SESSION_ID`` only when no log is given.
"""
from __future__ import annotations
@@ -88,20 +95,67 @@ def usage_and_cost(model: str, total_tokens: int) -> tuple[int, float]:
)
+def capture_session_usage(
+ *,
+ cwd: str,
+ session_id: str,
+ model: str,
+ out_path: Path,
+ grok_home: Path | None = None,
+) -> int:
+ """Write ``usage.json`` for one grok session; return its total tokens.
+
+ Reusable by the interactive driver after every turn — the chat reuses one
+ session id, so the session store's cumulative ``totalTokens`` is the running
+ whole-chat total and the last write wins. Best-effort: never raises (returns
+ 0 and writes nothing on any IO/lookup failure).
+ """
+ home = grok_home or Path(os.environ.get("GROK_HOME", str(Path.home() / ".grok")))
+ try:
+ updates = find_updates_path(home, cwd, session_id)
+ total = total_tokens_from_updates(updates) if updates else 0
+ tokens, cost = usage_and_cost(model, total)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(
+ json.dumps({"model": model, "total_tokens": tokens, "cost_usd": cost}),
+ encoding="utf-8",
+ )
+ return tokens
+ except OSError:
+ return 0
+
+
+def session_id_from_run_log(run_log: Path) -> str | None:
+ """Read the ``sessionId`` grok generated from its ``--output-format json`` log.
+
+ ``grok -p`` does not honour a requested session id, so the entrypoint hands
+ us the run's JSON output and we read the real id back. Returns ``None`` for a
+ missing / non-JSON / id-less log.
+ """
+ try:
+ payload = json.loads(run_log.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ if not isinstance(payload, dict):
+ return None
+ sid = payload.get("sessionId") or payload.get("session_id")
+ return sid if isinstance(sid, str) and sid else None
+
+
def main() -> int:
"""Entrypoint: write ``usage.json`` (model, total_tokens, cost) for the run."""
- grok_home = Path(os.environ.get("GROK_HOME", str(Path.home() / ".grok")))
cwd = os.environ.get("ROBOCO_GROK_RUN_CWD", str(Path.cwd()))
- session_id = os.environ.get("ROBOCO_AGENT_SESSION_ID", "")
model = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build")
- updates = find_updates_path(grok_home, cwd, session_id)
- total = total_tokens_from_updates(updates) if updates else 0
- tokens, cost = usage_and_cost(model, total)
+ # grok's real session id: from the run's JSON log if the entrypoint passed
+ # one (`-s` does not pin the id), else the orchestrator-supplied fallback.
+ run_log = os.environ.get("ROBOCO_GROK_RUN_LOG", "")
+ session_id = (run_log and session_id_from_run_log(Path(run_log))) or os.environ.get(
+ "ROBOCO_AGENT_SESSION_ID", ""
+ )
- USAGE_OUT_PATH.write_text(
- json.dumps({"model": model, "total_tokens": tokens, "cost_usd": cost}),
- encoding="utf-8",
+ capture_session_usage(
+ cwd=cwd, session_id=session_id, model=model, out_path=USAGE_OUT_PATH
)
return 0
diff --git a/roboco/llm/providers/opencode_config.py b/roboco/llm/providers/opencode_config.py
deleted file mode 100644
index 4fb1b4a2..00000000
--- a/roboco/llm/providers/opencode_config.py
+++ /dev/null
@@ -1,213 +0,0 @@
-"""Generate an ``opencode.json`` for a Grok (xAI) agent at container start.
-
-The ``roboco-agent-grok`` image's entrypoint runs ``python -m
-roboco.llm.providers.opencode_config`` to turn the env contract ``GrokProvider``
-sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code
-``mcp-config.json`` into the ``opencode.json`` that opencode reads. Keeping this
-as importable Python (not a shell heredoc) makes the translation unit-testable.
-
-Config shape per opencode docs (https://opencode.ai/docs/config):
- * NO ``provider`` block — opencode's BUILT-IN xai provider already drives
- grok-build-0.1 (model resolution + tool-calls verified live), so a custom
- block is unnecessary. The key + base URL reach the provider via the
- ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars; ``model`` selects ``xai/``.
- * ``mcp.`` — ``{type:"local", command:[...], environment:{...}}``; this
- is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired,
- translated from Claude Code's ``mcpServers`` (``command`` + ``args`` + ``env``).
- * ``permission.{bash,edit,external_directory}`` and ``instructions`` (system
- prompt + briefing).
- * ``tools`` — opencode's subagent ``task`` tool is hard-disabled. No RoboCo role
- uses opencode-internal subagents (work is driven through the gateway verbs),
- and a ``task``-spawned subagent on ``grok-build-0.1`` whose model call opens an
- idle stream hangs the parent run with no recovery (observed live on a PR
- review). This is the primary idle-stream defence (the orchestrator reaper is
- the backstop).
-
-There is NO ``plugin`` key: the plugins are baked into opencode's plugin
-AUTO-DISCOVERY dir (``~/.config/opencode/plugin/``, i.e.
-``/home/agent/.config/opencode/plugin/`` in the image), so the generated config
-doesn't need to reference them by path (secret-scrub + budget-feed in the base
-grok image; the Secretary's directive tools and the Intake's propose_draft in
-their interactive images). Each uses a NAMED export (opencode's documented
-plugin convention). The model writes this config to the GLOBAL location (see
-``main`` — ``~/.config/opencode/opencode.json``), which is what opencode reads.
-
-GUARDRAIL PARITY: the bash-guard (PAT-scrub) is ported via ``secret-scrub.js``
-(``tool.execute.before``); the per-session budget / loop / terminal-verb
-counters and the per-verb circuit breaker are restored by starting the same
-in-container SDK server the Claude path runs (the grok entrypoint launches
-``roboco.agent_sdk.server``) and feeding it from ``budget-feed.js``
-(``tool.execute.{before,after}``); usage/cost is captured from opencode's SQLite
-store at finalize and bounded by the orchestrator cost watchdog
-(``ROBOCO_GROK_MAX_COST_USD``); the prompt-injection guard is recreated at
-RoboCo's input boundary (``roboco.agent_sdk.prompt_guard``); and the SessionEnd
-post-mortem + Stop silent-exit substitute run at the entrypoint boundary after
-``opencode run`` returns. ``bash`` / ``edit`` permissions are scoped per role
-(read-only roles get ``edit=deny``; only delivery roles get ``bash``) and stay
-operator-tunable (``ROBOCO_GROK_BASH_PERMISSION`` / ``ROBOCO_GROK_EDIT_PERMISSION``).
-"""
-
-from __future__ import annotations
-
-import json
-import os
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Any
-
-_OPENCODE_SCHEMA = "https://opencode.ai/config.json"
-_PROVIDER_ID = "xai"
-# No provider block at all: opencode's BUILT-IN xai provider already drives
-# grok-build-0.1 with working tool-calls (verified live), so no custom `npm` /
-# `models` override is needed. The xAI key is injected via the XAI_API_KEY env
-# var the built-in provider reads (set by GrokProvider / the orchestrator).
-#
-# Plugins (secret-scrub / budget-feed / the per-role tool plugins) are baked into
-# the plugin AUTO-DISCOVERY dir (~/.config/opencode/plugin/, i.e.
-# /home/agent/.config/opencode/plugin/ in the image) rather than referenced by a
-# config `plugin:` path — the dir is the simplest registration route and keeps
-# the generated config path-free (registration verified live against grok-build-0.1).
-
-
-# opencode's built-in subagent-spawning tool. Hard-disabled in the generated
-# config (see the module docstring): a RoboCo agent never spawns opencode's own
-# subagents, and one that does can wedge the parent run on an idle stream. This
-# is the primary defence against the idle-stream hang; the orchestrator's
-# reaper watchdog (_maybe_kill_wedged_grok) is the backstop. (Per-provider
-# request/stream timeouts would need a custom provider block, which we don't
-# emit; the reaper + disabled subagents cover the idle-stream risk instead.)
-_SUBAGENT_TOOL = "task"
-
-
-@dataclass(frozen=True)
-class OpencodeGuards:
- """Tunable runtime guards baked into a Grok ``opencode.json``.
-
- ``bash``/``edit`` gate the command/file tools; ``external_directory`` gates
- reading paths outside the project cwd (opencode auto-DENIES an ``ask`` in
- headless mode, which blocked the pr-reviewer from reading a diff it wrote to
- /tmp — so default ``allow``: the container is the sandbox and secret-scrub
- still blocks credential files); ``disable_subagents`` removes the subagent
- ``task`` tool.
- """
-
- bash_permission: str = "allow"
- edit_permission: str = "allow"
- external_directory_permission: str = "allow"
- disable_subagents: bool = True
-
-
-def translate_mcp_servers(mcp_config: dict[str, Any]) -> dict[str, Any]:
- """Translate Claude Code ``mcpServers`` into opencode's ``mcp`` block.
-
- ``{"command": "uv", "args": [...], "env": {...}}`` becomes
- ``{"type": "local", "command": ["uv", ...], "environment": {...},
- "enabled": True}``.
- """
- servers = mcp_config.get("mcpServers", {})
- out: dict[str, Any] = {}
- for name, spec in servers.items():
- command = spec.get("command")
- args = list(spec.get("args", []))
- cmd_list = [command, *args] if command else args
- entry: dict[str, Any] = {
- "type": "local",
- "command": cmd_list,
- "enabled": True,
- }
- env = spec.get("env")
- if env:
- entry["environment"] = env
- out[name] = entry
- return out
-
-
-def build_opencode_config(
- mcp_config: dict[str, Any],
- model: str,
- *,
- instruction_paths: list[str],
- guards: OpencodeGuards | None = None,
-) -> dict[str, Any]:
- """Build the ``opencode.json`` dict for a Grok agent.
-
- Emits NO ``provider`` block — opencode's BUILT-IN xai provider drives
- grok-build-0.1 (verified live), so a custom block is unnecessary; the key +
- base URL are injected via the ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars
- (set by GrokProvider / the orchestrator). No ``plugin`` array either —
- plugins live in the auto-discovery dir baked into the images.
- """
- guards = guards or OpencodeGuards()
- config: dict[str, Any] = {
- "$schema": _OPENCODE_SCHEMA,
- "model": f"{_PROVIDER_ID}/{model}",
- "mcp": translate_mcp_servers(mcp_config),
- "permission": {
- "bash": guards.bash_permission,
- "edit": guards.edit_permission,
- # Reading paths outside the project cwd (e.g. /tmp scratch). opencode
- # auto-denies an "ask" in headless mode, which blocked the pr-reviewer
- # from reading a diff it wrote to /tmp; "allow" since the container is
- # the sandbox and secret-scrub still blocks credential files.
- "external_directory": guards.external_directory_permission,
- },
- "instructions": instruction_paths,
- }
- if guards.disable_subagents:
- # Remove the subagent tool entirely so the model can never invoke it.
- config["tools"] = {_SUBAGENT_TOOL: False}
- return config
-
-
-def _load_mcp_config(path: str) -> dict[str, Any]:
- """Load the mounted mcp-config.json, tolerating a missing/invalid file."""
- try:
- with Path(path).open() as fh:
- data: dict[str, Any] = json.load(fh)
- return data
- except (OSError, json.JSONDecodeError):
- return {}
-
-
-def main() -> int:
- """Entrypoint: read env + mounted mcp-config.json, write opencode.json.
-
- The xAI key + base URL are NOT read here — they reach opencode's built-in
- xai provider via the ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars.
- """
- model = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build-0.1")
- mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json")
- system_prompt = os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md")
- # Default to opencode's global config location so it is found regardless of
- # the agent's working directory (cwd is the per-agent workspace at spawn).
- out_path = os.environ.get(
- "ROBOCO_OPENCODE_CONFIG",
- str(Path.home() / ".config" / "opencode" / "opencode.json"),
- )
- guards = OpencodeGuards(
- bash_permission=os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow"),
- edit_permission=os.environ.get("ROBOCO_GROK_EDIT_PERMISSION", "allow"),
- external_directory_permission=os.environ.get(
- "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION", "allow"
- ),
- )
-
- # Instructions = system prompt + the SessionStart briefing when mounted.
- candidates = [system_prompt, "/app/briefing.md"]
- instructions = [p for p in candidates if p and Path(p).exists()]
-
- config = build_opencode_config(
- _load_mcp_config(mcp_path),
- model,
- instruction_paths=instructions,
- guards=guards,
- )
- out = Path(out_path)
- out.parent.mkdir(parents=True, exist_ok=True)
- with out.open("w") as fh:
- json.dump(config, fh, indent=2)
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/roboco/llm/providers/opencode_usage.py b/roboco/llm/providers/opencode_usage.py
deleted file mode 100644
index 7bf2c6ba..00000000
--- a/roboco/llm/providers/opencode_usage.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""Read token usage from an opencode SQLite store — Grok agent cost capture.
-
-opencode (v1.x) persists per-session usage in a SQLite DB at
-``~/.local/share/opencode/opencode.db`` (confirmed by inspecting a local run:
-the ``session`` table carries ``cost`` and ``tokens_input`` / ``tokens_output``
-/ ``tokens_reasoning`` / ``tokens_cache_read`` / ``tokens_cache_write``).
-
-A Grok agent runs opencode, so its usage lands there rather than in a Claude
-Code transcript. The orchestrator reads this at agent finalize and feeds the
-token counts to :func:`roboco.billing.pricing.calculate_cost` — keeping our
-pricing authoritative — while opencode's own ``cost`` column is kept for
-reference. A per-agent container has a single opencode store, so summing all
-session rows is correct without needing to map an opencode session id.
-"""
-
-from __future__ import annotations
-
-import sqlite3
-from dataclasses import dataclass
-from pathlib import Path
-
-from roboco.billing.pricing import calculate_cost
-
-# Default location inside the agent container (HOME=/home/agent).
-DEFAULT_DB_PATH = "/home/agent/.local/share/opencode/opencode.db"
-
-# Column order here MUST match the unpacking in read_session_usage below.
-_SELECT_ALL = (
- "SELECT tokens_input, tokens_output, tokens_cache_read, "
- "tokens_cache_write, tokens_reasoning, cost FROM session"
-)
-_SELECT_ONE = _SELECT_ALL + " WHERE id = ?"
-
-
-@dataclass(frozen=True)
-class OpencodeUsage:
- """Aggregated token usage read from an opencode store."""
-
- tokens_input: int
- tokens_output: int
- tokens_cache_read: int
- tokens_cache_write: int
- tokens_reasoning: int
- opencode_cost: float # opencode's own computed cost (reference only)
-
-
-def read_session_usage(
- db_path: str | Path = DEFAULT_DB_PATH,
- session_id: str | None = None,
-) -> OpencodeUsage | None:
- """Read aggregated usage from an opencode SQLite store.
-
- Reads the ``session`` table — a specific row when ``session_id`` is given,
- otherwise the sum across all sessions (one store per agent container).
- Returns ``None`` if the DB is missing, the table absent, or there are no
- rows — never raises, so callers don't need to guard finalize on it.
- """
- path = Path(db_path)
- if not path.exists():
- return None
-
- try:
- con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
- try:
- if session_id is not None:
- cur = con.execute(_SELECT_ONE, (session_id,))
- else:
- cur = con.execute(_SELECT_ALL)
- rows = cur.fetchall()
- finally:
- con.close()
- except sqlite3.Error:
- return None
-
- if not rows:
- return None
-
- totals = [0, 0, 0, 0, 0]
- cost = 0.0
- for row in rows:
- for i in range(5):
- totals[i] += int(row[i] or 0)
- cost += float(row[5] or 0.0)
-
- return OpencodeUsage(
- tokens_input=totals[0],
- tokens_output=totals[1],
- tokens_cache_read=totals[2],
- tokens_cache_write=totals[3],
- tokens_reasoning=totals[4],
- opencode_cost=cost,
- )
-
-
-def cost_for_session(
- model: str,
- db_path: str | Path = DEFAULT_DB_PATH,
- session_id: str | None = None,
-) -> tuple[OpencodeUsage | None, float]:
- """Return (usage, roboco_cost_usd) for a Grok agent's opencode session.
-
- ``roboco_cost_usd`` is computed from our own pricing table so cost is
- consistent with the Claude path. Returns ``(None, 0.0)`` when no usage is
- recorded yet.
- """
- usage = read_session_usage(db_path, session_id)
- if usage is None:
- return None, 0.0
- # opencode stores tokens_input as non-cached input (disjoint from
- # tokens_cache_read) and tokens_output EXCLUDING reasoning, with reasoning
- # in its own column. Reasoning bills at the output rate, so fold it into
- # output. Verified against a live run: this reproduces opencode's own `cost`
- # column (= xAI's authoritative cost) to the cent.
- cost = calculate_cost(
- model,
- tokens_input=usage.tokens_input,
- tokens_output=usage.tokens_output + usage.tokens_reasoning,
- tokens_cache_read=usage.tokens_cache_read,
- tokens_cache_write=usage.tokens_cache_write,
- )
- return usage, cost
diff --git a/roboco/mcp/intake_server.py b/roboco/mcp/intake_server.py
new file mode 100644
index 00000000..6ab972c4
--- /dev/null
+++ b/roboco/mcp/intake_server.py
@@ -0,0 +1,95 @@
+"""roboco-intake MCP server — the Intake interviewer's ``propose_draft`` tool.
+
+The grok-CLI interactive intake agent calls ``propose_draft`` once the task spec
+is ready; this delivers the draft to the panel's reviewable draft card by POSTing
+it straight to the prompter-live relay (the same ``/api/prompter/live/{session}/
+events`` endpoint the driver's relay sink uses).
+
+WHY IT POSTS DIRECTLY: grok's ``streaming-json`` output does not surface
+tool-call events (verified live — a tool runs but never appears in the stream),
+so :class:`~roboco.agent_sdk.grok_cli_session.GrokCliSession` cannot intercept
+this call to emit a ``draft`` chunk. The tool POSTs the draft itself. (The Claude
+intake path differs: the Claude SDK exposes the tool-use block, so its driver
+intercepts it.)
+
+Wired into ``~/.grok/config.toml`` by ``grok_intake_main``; the container
+provides ``ROBOCO_API_URL`` + ``ROBOCO_PROMPTER_SESSION_ID``.
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+
+import httpx
+from mcp.server.fastmcp import FastMCP
+
+_TIMEOUT = 15.0
+
+mcp = FastMCP("roboco-intake")
+
+
+def _api_base() -> str:
+ return os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000").rstrip(
+ "/"
+ )
+
+
+async def post_draft(
+ session_id: str,
+ draft: dict[str, Any],
+ *,
+ client: httpx.AsyncClient | None = None,
+) -> dict[str, Any]:
+ """POST the draft to the prompter-live relay; never raises.
+
+ Module-level so it is unit-testable with ``httpx.MockTransport`` (the tool
+ wrapper below only shapes the result string).
+ """
+ owns = client is None
+ http = client or httpx.AsyncClient(timeout=_TIMEOUT)
+ url = f"{_api_base()}/api/prompter/live/{session_id}/events"
+ try:
+ resp = await http.post(
+ url,
+ json={
+ "kind": "draft",
+ "text": "",
+ "tool": "propose_draft",
+ "data": draft,
+ },
+ )
+ except httpx.HTTPError as exc:
+ return {"error": "request_failed", "detail": str(exc)}
+ finally:
+ if owns:
+ await http.aclose()
+ if not resp.is_success:
+ return {"error": f"http_{resp.status_code}"}
+ return {"ok": True}
+
+
+@mcp.tool()
+async def propose_draft(draft: dict[str, Any]) -> str:
+ """Submit the finished task draft for the human to review and confirm.
+
+ Call this once the spec is complete. Pass a JSON object: title, objective,
+ what_this_builds[], the_work[] ({team, summary, items}), notes[],
+ acceptance_criteria[], team, scale, task_type, nature, estimated_complexity,
+ priority.
+ """
+ session_id = os.environ.get("ROBOCO_PROMPTER_SESSION_ID", "")
+ if not session_id:
+ return (
+ "No live session id (ROBOCO_PROMPTER_SESSION_ID) — cannot surface the "
+ "draft."
+ )
+ result = await post_draft(session_id, draft or {})
+ if result.get("ok"):
+ return "Draft submitted — the human can review it in the panel."
+ detail = result.get("detail") or result.get("error") or "unknown error"
+ return f"Could not submit the draft to the panel: {detail}"
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/roboco/mcp/secretary_server.py b/roboco/mcp/secretary_server.py
new file mode 100644
index 00000000..7df88813
--- /dev/null
+++ b/roboco/mcp/secretary_server.py
@@ -0,0 +1,64 @@
+"""roboco-secretary MCP server — the Secretary's CEO-authority tools.
+
+Parity with the Claude Secretary's SDK tools
+(:func:`roboco.agent_sdk.secretary_driver.build_secretary_options`):
+``read_company_state`` / ``read_task`` (reads) and ``submit_directive`` (acts).
+Each calls the backend ``/api/secretary/*`` routes with the container's HMAC
+agent token; the backend gate-list queues high-impact directive kinds for the
+CEO's confirmation and runs low-risk ones directly. The backend-calling logic is
+reused verbatim from ``secretary_driver`` (the SDK and grok paths share one
+HTTP seam), so this server only wraps those helpers as MCP tools.
+
+Wired into ``~/.grok/config.toml`` by ``grok_secretary_main``; the container
+provides ``ROBOCO_API_URL`` / ``ROBOCO_AGENT_ID`` / ``ROBOCO_AGENT_ROLE`` /
+``ROBOCO_AGENT_TOKEN`` (the same auth substrate the one-shot Grok path uses).
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from mcp.server.fastmcp import FastMCP
+
+from roboco.agent_sdk.secretary_driver import (
+ _do_read_state,
+ _do_read_task,
+ _do_submit_directive,
+)
+
+mcp = FastMCP("roboco-secretary")
+
+
+@mcp.tool()
+async def read_company_state() -> str:
+ """Read a compact snapshot of company state.
+
+ The charter (goals), task counts by status, pending pitches, and any
+ directives awaiting the CEO's confirmation.
+ """
+ return json.dumps(await _do_read_state())
+
+
+@mcp.tool()
+async def read_task(task_id: str) -> str:
+ """Read one task's detail by its id."""
+ return json.dumps(await _do_read_task(task_id))
+
+
+@mcp.tool()
+async def submit_directive(kind: str, payload: dict[str, Any]) -> str:
+ """Act on the CEO's command.
+
+ 'kind' is one of: relay_message (payload: channel, text), update_charter
+ (payload: charter), control_task (payload: task_id, action[start|cancel|
+ override], status?), approve_pitch (payload: pitch_id, notes?), announce
+ (payload: text). High-impact kinds (charter, control_task, approve_pitch,
+ announce) are queued for the CEO's explicit confirmation; relay_message runs
+ directly.
+ """
+ return json.dumps(await _do_submit_directive(kind, payload or {}))
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py
index 914f0bd2..7e8be848 100644
--- a/roboco/runtime/orchestrator.py
+++ b/roboco/runtime/orchestrator.py
@@ -163,15 +163,16 @@ CLAUDE_AUTH_HOST_PATH = os.environ.get(
)
PROJECT_HOST_PATH = os.environ.get("ROBOCO_HOST_PROJECT_DIR", "")
DATA_HOST_PATH = os.environ.get("ROBOCO_HOST_DATA_DIR", "")
-# In-orchestrator path where each GROK agent's opencode store is visible. The
-# agent writes /opencode/; the compose file mounts the
-# same host dir here so the finalizer can read opencode.db back (mirrors how the
-# Claude transcript is read from the mounted ~/.claude). Override for local runs.
-OPENCODE_DATA_DIR = os.environ.get("ROBOCO_OPENCODE_DATA_DIR", "/data/opencode")
+# In-orchestrator path where each GROK agent's usage capture is visible. The
+# agent writes /grok-usage//usage.json; the compose file
+# mounts the same host dir here so the finalizer can read the captured tokens back
+# (the grok analogue of reading the Claude transcript from the mounted ~/.claude).
+# Override for local runs.
+GROK_USAGE_DATA_DIR = os.environ.get("ROBOCO_GROK_USAGE_DIR", "/data/grok-usage")
-# Interactive Grok images (opencode-serve drivers) — selected for the intake /
-# secretary roles when their route resolves to GROK, instead of the Claude
-# prompter/secretary images. Their dockerfiles build FROM roboco-agent-grok.
+# Interactive Grok images (grok-CLI conversation drivers) — selected for the
+# intake / secretary roles when their route resolves to GROK, instead of the
+# Claude prompter/secretary images. Their dockerfiles build FROM roboco-agent-grok.
GROK_PROMPTER_IMAGE = "roboco-agent-grok-prompter"
GROK_SECRETARY_IMAGE = "roboco-agent-grok-secretary"
_GROK_INTERACTIVE_DOCKERFILES = {
@@ -219,7 +220,6 @@ class _IntakeRunSpec:
provider_auth_token: str | None
provider_type: str = "anthropic"
model: str = ""
- grok_variant: str | None = None
@dataclass
@@ -243,7 +243,6 @@ class _SecretaryRunSpec:
provider_auth_token: str | None
provider_type: str = "anthropic"
model: str = ""
- grok_variant: str | None = None
def _read_project_slug(task: dict[str, Any]) -> str | None:
@@ -716,8 +715,8 @@ class AgentOrchestrator:
# _maybe_kill_wedged_grok.
self._grok_idle_kill_ttl: int = settings.grok_idle_kill_seconds
# Cost ceiling (USD) before a live GROK container is killed — the budget
- # kill-switch parity (opencode exposes no usage hook). 0 disables. See
- # _enforce_grok_cost_budget.
+ # kill-switch parity (the grok CLI exposes no live usage hook). 0 disables.
+ # See _enforce_grok_cost_budget.
self._grok_max_cost_usd: float = settings.grok_max_cost_usd
# =========================================================================
@@ -878,27 +877,26 @@ class AgentOrchestrator:
img, f"{docker_dir}/{dockerfile}", build_context
)
- def _ensure_opencode_data_dir(self, agent_id: str) -> None:
- """Pre-create the agent's opencode store dir (world-writable) before the mount.
+ def _ensure_grok_usage_dir(self, agent_id: str) -> None:
+ """Pre-create the agent's grok usage dir (world-writable) before the mount.
On Linux, ``docker run -v`` auto-creates a MISSING bind source as
- ``root:root``, so the non-root ``agent`` user EACCESes on opencode's
- first write (``repos/``, ``opencode.db``) and ``opencode serve``/``run``
- dies at boot — the live intake crash. Creating the dir ``0777`` first
- makes the mounted store writable regardless of the agent uid; the
- orchestrator (root) can still read it back at finalize. Mirrors the
- container-vs-local split in ``_resolve_host_paths``.
+ ``root:root``, so the non-root ``agent`` user EACCESes when the grok
+ entrypoint / interactive driver writes ``usage.json`` there. Creating the
+ dir ``0777`` first makes the mounted dir writable regardless of the agent
+ uid; the orchestrator (root) can still read it back at finalize. Mirrors
+ the container-vs-local split in ``_resolve_host_paths``.
"""
if PROJECT_HOST_PATH:
- target = Path(OPENCODE_DATA_DIR) / agent_id
+ target = Path(GROK_USAGE_DATA_DIR) / agent_id
else:
- target = Path(tempfile.gettempdir()) / "roboco-opencode" / agent_id
+ target = Path(tempfile.gettempdir()) / "roboco-grok-usage" / agent_id
try:
target.mkdir(parents=True, exist_ok=True)
target.chmod(0o777)
except OSError as exc:
logger.warning(
- "could not pre-create opencode data dir; grok agent may EACCES",
+ "could not pre-create grok usage dir; grok agent may EACCES",
agent_id=agent_id,
path=str(target),
error=str(exc),
@@ -1790,9 +1788,10 @@ class AgentOrchestrator:
"workspaces": f"{DATA_HOST_PATH}/workspaces",
"claude": CLAUDE_AUTH_HOST_PATH,
"mcp_config": f"{DATA_HOST_PATH}/mcp-configs/{mcp_name}",
- # Per-agent opencode store (GROK only); the orchestrator reads it
- # back at finalize via the shared data volume (see OPENCODE_DATA_DIR).
- "opencode": f"{DATA_HOST_PATH}/opencode/{config.agent_id}",
+ # Per-agent grok usage dir (GROK only); the orchestrator reads the
+ # captured tokens back at finalize via the shared data volume
+ # (see GROK_USAGE_DATA_DIR).
+ "grok_usage": f"{DATA_HOST_PATH}/grok-usage/{config.agent_id}",
"prompt": (
f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md"
),
@@ -1812,8 +1811,8 @@ class AgentOrchestrator:
"workspaces": str(Path(settings.workspaces_root)),
"claude": CLAUDE_AUTH_HOST_PATH,
"mcp_config": str(config.mcp_config_path),
- "opencode": str(
- Path(tempfile.gettempdir()) / "roboco-opencode" / config.agent_id
+ "grok_usage": str(
+ Path(tempfile.gettempdir()) / "roboco-grok-usage" / config.agent_id
),
"prompt": str(
Path(tempfile.gettempdir())
@@ -3022,17 +3021,13 @@ class AgentOrchestrator:
else f"http://127.0.0.1:{settings.port}"
)
- # GROK runs the interactive driver on its own opencode-serve image; every
- # other provider uses the Claude SDK-driver prompter image.
+ # GROK runs the interactive driver on its own grok-CLI prompter image;
+ # every other provider uses the Claude SDK-driver prompter image.
is_grok = route.provider_type == ModelProvider.GROK
image = GROK_PROMPTER_IMAGE if is_grok else get_agent_image(INTAKE_AGENT_ID)
- grok_variant: str | None = None
if is_grok:
- from roboco.llm.providers.grok import _reasoning_effort_for
-
- grok_variant = _reasoning_effort_for(INTAKE_AGENT_ID)
await self._ensure_grok_interactive_image(image)
- self._ensure_opencode_data_dir(INTAKE_AGENT_ID)
+ self._ensure_grok_usage_dir(INTAKE_AGENT_ID)
else:
await self._ensure_agent_image(INTAKE_AGENT_ID)
container_name = f"roboco-agent-{INTAKE_AGENT_ID}"
@@ -3051,7 +3046,6 @@ class AgentOrchestrator:
provider_auth_token=route.auth_token,
provider_type=route.provider_type.value,
model=route.model_name,
- grok_variant=grok_variant,
)
)
container_id = await self._run_container_cmd(cmd)
@@ -3076,8 +3070,8 @@ class AgentOrchestrator:
# Record a usage session (task_id=None) and pin its id on the instance so
# the reap finalizer can look up token usage — without this an interactive
- # session finalizes at 0 tokens / $0 (the GROK path reads opencode.db by
- # this id; the Claude path reads the transcript). Mirrors _launch_spawn.
+ # session finalizes at 0 tokens / $0 (the GROK path reads the captured
+ # usage.json; the Claude path reads the transcript). Mirrors _launch_spawn.
usage_session_id = await self._record_spawn_session(config, None)
if usage_session_id is not None:
instance.usage_session_id = usage_session_id
@@ -3194,13 +3188,9 @@ class AgentOrchestrator:
is_grok = route.provider_type == ModelProvider.GROK
image = GROK_SECRETARY_IMAGE if is_grok else get_agent_image(SECRETARY_AGENT_ID)
- grok_variant: str | None = None
if is_grok:
- from roboco.llm.providers.grok import _reasoning_effort_for
-
- grok_variant = _reasoning_effort_for(SECRETARY_AGENT_ID)
await self._ensure_grok_interactive_image(image)
- self._ensure_opencode_data_dir(SECRETARY_AGENT_ID)
+ self._ensure_grok_usage_dir(SECRETARY_AGENT_ID)
else:
await self._ensure_agent_image(SECRETARY_AGENT_ID)
container_name = f"roboco-agent-{SECRETARY_AGENT_ID}"
@@ -3222,7 +3212,6 @@ class AgentOrchestrator:
provider_auth_token=route.auth_token,
provider_type=route.provider_type.value,
model=route.model_name,
- grok_variant=grok_variant,
)
)
container_id = await self._run_container_cmd(cmd)
@@ -3319,7 +3308,7 @@ class AgentOrchestrator:
"prompt": (
f"{DATA_HOST_PATH}/prompts-generated/{SECRETARY_AGENT_ID}-prompt.md"
),
- "opencode": f"{DATA_HOST_PATH}/opencode/{SECRETARY_AGENT_ID}",
+ "grok_usage": f"{DATA_HOST_PATH}/grok-usage/{SECRETARY_AGENT_ID}",
}
return {
"claude": CLAUDE_AUTH_HOST_PATH,
@@ -3328,8 +3317,8 @@ class AgentOrchestrator:
/ "roboco-prompts"
/ f"{SECRETARY_AGENT_ID}-prompt.md"
),
- "opencode": str(
- Path(tempfile.gettempdir()) / "roboco-opencode" / SECRETARY_AGENT_ID
+ "grok_usage": str(
+ Path(tempfile.gettempdir()) / "roboco-grok-usage" / SECRETARY_AGENT_ID
),
}
@@ -3436,7 +3425,7 @@ class AgentOrchestrator:
f"{DATA_HOST_PATH}/prompts-generated/{INTAKE_AGENT_ID}-prompt.md"
),
"workspaces": f"{DATA_HOST_PATH}/workspaces",
- "opencode": f"{DATA_HOST_PATH}/opencode/{INTAKE_AGENT_ID}",
+ "grok_usage": f"{DATA_HOST_PATH}/grok-usage/{INTAKE_AGENT_ID}",
}
return {
"claude": CLAUDE_AUTH_HOST_PATH,
@@ -3446,8 +3435,8 @@ class AgentOrchestrator:
/ f"{INTAKE_AGENT_ID}-prompt.md"
),
"workspaces": str(Path(settings.workspaces_root)),
- "opencode": str(
- Path(tempfile.gettempdir()) / "roboco-opencode" / INTAKE_AGENT_ID
+ "grok_usage": str(
+ Path(tempfile.gettempdir()) / "roboco-grok-usage" / INTAKE_AGENT_ID
),
}
@@ -3457,59 +3446,31 @@ class AgentOrchestrator:
) -> None:
"""Inject the per-provider LLM env for an interactive container.
- GROK runs natively on opencode: ``OPENAI_*`` (xAI) + ``ROBOCO_AGENT_MODEL``
- + the mounted system prompt the driver renders ``opencode.json`` from,
- plus the per-agent opencode store mount so finalize can read usage back.
- Every other provider uses the Claude path's ``ANTHROPIC_*`` injection (or
- the mounted ``~/.claude`` default when the route carries no creds).
+ GROK runs on the official ``grok`` CLI, exactly like the one-shot path:
+ the subscription auth (``~/.grok/auth.json``) is mounted read-only, no
+ metered xAI key is used, the per-agent data dir is mounted so the driver's
+ per-turn usage capture lands a ``usage.json`` the finalizer reads back, and
+ the per-role permissions / reasoning come from the grok flags the driver
+ computes (``grok_cli_config``) — not env. Every other provider uses the
+ Claude path's ``ANTHROPIC_*`` injection (or the mounted ``~/.claude``
+ default when the route carries no creds).
"""
+ from roboco.llm.providers.grok import GrokCliProvider
from roboco.models.base import ModelProvider
base_url = spec.provider_base_url
auth_token = spec.provider_auth_token
if spec.provider_type == ModelProvider.GROK.value:
- opencode_host = spec.hosts.get("opencode")
- if opencode_host:
- # opencode persists usage to opencode.db under its data dir; the
- # per-agent host mount lets the finalizer read it (same in-
- # container path as the one-shot Grok store).
- cmd.extend(["-v", f"{opencode_host}:/home/agent/.local/share/opencode"])
- cmd.extend(
- [
- # Built-in xai provider authenticates from XAI_API_KEY and
- # reads XAI_BASE_URL; opencode_config emits no provider block,
- # so these envs are the only LLM wiring needed.
- "-e",
- f"XAI_API_KEY={auth_token or ''}",
- "-e",
- f"XAI_BASE_URL={base_url or 'https://api.x.ai/v1'}",
- "-e",
- f"ROBOCO_AGENT_MODEL={spec.model}",
- "-e",
- "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md",
- ]
- )
- # Both interactive roles are read-only conversational agents: no code
- # edits, no shell (Claude-parity — the SDK gates deny everything but
- # Read/Grep/Glob + their tools). Intake reads sibling product repos
- # that sit OUTSIDE its cwd, so it keeps external-directory reads; the
- # Secretary only reads /app + the API, so it doesn't.
- is_intake = isinstance(spec, _IntakeRunSpec)
+ GrokCliProvider._append_grok_auth_mount(cmd)
+ GrokCliProvider._append_usage_mount(cmd, spec.hosts)
cmd.extend(
[
"-e",
- "ROBOCO_GROK_EDIT_PERMISSION=deny",
+ "ROBOCO_AGENT_MODEL=grok-build",
"-e",
- "ROBOCO_GROK_BASH_PERMISSION=deny",
- "-e",
- "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION="
- f"{'allow' if is_intake else 'deny'}",
+ "ROBOCO_GROK_USAGE_FILE=/home/agent/.grok-usage/usage.json",
]
)
- # Per-role reasoning effort: the opencode-serve driver passes this as
- # the message `variant` (same lever as the one-shot --variant).
- if spec.grok_variant:
- cmd.extend(["-e", f"ROBOCO_GROK_VARIANT={spec.grok_variant}"])
return
if base_url:
cmd.extend(["-e", f"ANTHROPIC_BASE_URL={base_url}"])
@@ -3556,7 +3517,7 @@ class AgentOrchestrator:
f"CLAUDE_CODE_SUBAGENT_MODEL={spec.cli_model}",
]
)
- # GROK runs opencode (OPENAI_* + opencode store); other providers use the
+ # GROK mounts the subscription auth + usage dir; other providers use the
# ANTHROPIC_* injection or the mounted ~/.claude default.
AgentOrchestrator._append_interactive_provider_env(cmd, spec)
cmd.append(spec.image)
@@ -3923,77 +3884,70 @@ class AgentOrchestrator:
except OSError:
return (0, 0, 0, 0)
- def _opencode_db_path(self, agent_id: str) -> str:
- """In-orchestrator path to a GROK agent's opencode SQLite store.
+ def _grok_usage_json(self, agent_id: str) -> dict[str, Any] | None:
+ """Read a GROK agent's ``usage.json`` (``{model, total_tokens, cost_usd}``).
- The agent writes opencode.db under the shared data volume; the compose
- file mounts that host dir at ``OPENCODE_DATA_DIR`` here, so finalize can
- read it back — the opencode analogue of the mounted Claude transcript.
+ Written to the per-agent data dir by the grok-CLI entrypoint (one-shot,
+ post-run) and the interactive driver (per-turn); the orchestrator sees it
+ at ``GROK_USAGE_DATA_DIR``. Returns ``None`` when absent / unreadable.
"""
- return str(Path(OPENCODE_DATA_DIR) / agent_id / "opencode.db")
+ usage_json = Path(GROK_USAGE_DATA_DIR) / agent_id / "usage.json"
+ try:
+ data = json.loads(usage_json.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return data if isinstance(data, dict) else None
- def _grok_usage_from_opencode(self, agent_id: str) -> tuple[int, int, int, int]:
- """Sum a GROK agent's token usage from its per-agent data dir.
+ def _grok_usage_tokens(self, agent_id: str) -> tuple[int, int, int, int]:
+ """A GROK agent's token usage from its ``usage.json``.
- The grok-CLI one-shot path writes a ``usage.json`` (``total_tokens``)
- there post-run — read that first. The interactive opencode-serve path
- still lands its usage in ``opencode.db``, so fall back to it. The grok
- total folds into output (it bills at the output rate, matching
- ``calculate_cost``). A WARNING is logged on a 0-token read because a
+ grok reports a single cumulative total with no input/output split, so it
+ folds into output (it bills at the output rate, matching
+ ``calculate_cost``). A WARNING is logged on a missing/zero read because a
silent mount/uid failure is otherwise indistinguishable from a genuine
zero-cost run. Returns ``(input, output, cache_read, cache_write)``.
"""
- cli_tokens = self._grok_cli_total_tokens(agent_id)
- if cli_tokens is not None:
- return (0, cli_tokens, 0, 0)
-
- from roboco.llm.providers.opencode_usage import read_session_usage
-
- db_path = self._opencode_db_path(agent_id)
- usage = read_session_usage(db_path)
- if usage is None:
+ data = self._grok_usage_json(agent_id)
+ total = 0
+ if data:
+ try:
+ total = int(data.get("total_tokens", 0))
+ except (TypeError, ValueError):
+ total = 0
+ if not total:
logger.warning(
"GROK agent finalized with no readable usage "
"(0 tokens / $0) — check the data dir mount",
agent_id=agent_id,
- db_path=db_path,
)
- return (0, 0, 0, 0)
- return (
- usage.tokens_input,
- usage.tokens_output + usage.tokens_reasoning,
- usage.tokens_cache_read,
- usage.tokens_cache_write,
- )
+ return (0, total, 0, 0)
- @staticmethod
- def _grok_cli_total_tokens(agent_id: str) -> int | None:
- """Total tokens from a grok-CLI ``usage.json``, or None if absent.
-
- The grok-cli entrypoint writes ``{model, total_tokens, cost_usd}`` to the
- per-agent data dir; the orchestrator sees it at ``OPENCODE_DATA_DIR``.
- """
- usage_json = Path(OPENCODE_DATA_DIR) / agent_id / "usage.json"
+ def _grok_cost_usd(self, agent_id: str) -> float:
+ """A GROK agent's captured notional cost from its ``usage.json`` (0 if none)."""
+ data = self._grok_usage_json(agent_id)
+ if not data:
+ return 0.0
try:
- data = json.loads(usage_json.read_text(encoding="utf-8"))
- return int(data.get("total_tokens", 0))
- except (OSError, json.JSONDecodeError, ValueError, TypeError):
- return None
+ return float(data.get("cost_usd", 0.0))
+ except (TypeError, ValueError):
+ return 0.0
async def _enforce_grok_cost_budget(self) -> None:
- """Kill a live GROK container whose cumulative opencode cost exceeds the cap.
+ """Kill a live GROK container whose captured cost exceeds the cap.
- opencode exposes no token/budget hook to a plugin, so the budget
- kill-switch (Claude Code parity for runaway token burn — a loop that
- keeps firing verbs evades the idle watchdog but still burns cost) lives
- here: read each ACTIVE GROK container's cumulative cost from its opencode
- store and kill + evict it past ``ROBOCO_GROK_MAX_COST_USD``. The reaper
- then releases the freed task. Disabled (no-op) when the cap is <= 0.
+ The grok CLI exposes no live token/budget hook, so the budget kill-switch
+ (Claude Code parity for runaway token burn — a loop that keeps firing
+ verbs evades the idle watchdog but still burns cost) reads each ACTIVE
+ GROK container's captured cost from its ``usage.json`` and kills + evicts
+ it past ``ROBOCO_GROK_MAX_COST_USD``. The reaper then releases the freed
+ task. This bites on the interactive sessions (the driver rewrites
+ usage.json every turn, so a runaway chat is caught between turns); a
+ one-shot ``grok -p`` writes usage.json only post-run and is bounded by its
+ ``--max-turns`` cap instead. Disabled (no-op) when the cap is <= 0.
"""
cap = getattr(self, "_grok_max_cost_usd", 0.0)
if cap <= 0:
return
- from roboco.llm.providers.opencode_usage import cost_for_session
from roboco.models.base import ModelProvider
for agent_id, instance in list(self._instances.items()):
@@ -4004,9 +3958,7 @@ class AgentOrchestrator:
or instance.state != AgentState.ACTIVE
):
continue
- _, cost = cost_for_session(
- config.model or "", self._opencode_db_path(agent_id)
- )
+ cost = self._grok_cost_usd(agent_id)
if cost <= cap:
continue
try:
@@ -4040,17 +3992,17 @@ class AgentOrchestrator:
) -> tuple[int, int, int, int]:
"""Resolve final token counts for a stopping agent.
- For a GROK agent, reads the opencode SQLite store (no SDK server / Claude
- transcript exists). Otherwise tries the live SDK ``/usage/status`` first;
- if that misses — the SDK's in-memory counts race container teardown for
- short-lived agents — it falls back to the agent's Claude Code transcript,
- which is durable and mounted into this container. Returns
+ For a GROK agent, reads the captured ``usage.json`` (no SDK server /
+ Claude transcript exists). Otherwise tries the live SDK ``/usage/status``
+ first; if that misses — the SDK's in-memory counts race container teardown
+ for short-lived agents — it falls back to the agent's Claude Code
+ transcript, which is durable and mounted into this container. Returns
``(input, output, cache_read, cache_write)``.
"""
from roboco.models.base import ModelProvider
if self.get_provider_for_agent(agent_id) == ModelProvider.GROK.value:
- return self._grok_usage_from_opencode(agent_id)
+ return self._grok_usage_tokens(agent_id)
tokens = (0, 0, 0, 0)
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
@@ -6701,7 +6653,7 @@ Start now: evidence(task_id="{task_id}")
``_assignee_has_active_instance`` shields a live container from the
reaper — correct for a Claude agent quiet during a long edit/test cycle.
- A wedged opencode container is the one case that breaks: ACTIVE *and*
+ A wedged GROK container is the one case that breaks: ACTIVE *and*
silent (an idle model call fires no gateway verb), so its heartbeat never
advances and the skip would protect it forever. Returns the slug only for
a GROK instance idle past the grok-kill TTL — a recent heartbeat, no
@@ -6774,8 +6726,8 @@ Start now: evidence(task_id="{task_id}")
ts = t.last_heartbeat_at
if ts is None or ts < cutoff:
# A live container normally protects its task. The sole exception
- # is a wedged GROK (opencode) container — ACTIVE yet firing no
- # verb — which the live-instance skip would shield forever. Kill +
+ # is a wedged GROK container — ACTIVE yet firing no verb — which
+ # the live-instance skip would shield forever. Kill +
# evict it past the grok-idle TTL (then fall through to release);
# a live non-grok agent, or a grok within the TTL, is skipped.
if self._assignee_has_active_instance(
diff --git a/tests/unit/agent_sdk/test_grok_cli_session.py b/tests/unit/agent_sdk/test_grok_cli_session.py
new file mode 100644
index 00000000..ba64a5a7
--- /dev/null
+++ b/tests/unit/agent_sdk/test_grok_cli_session.py
@@ -0,0 +1,95 @@
+"""grok_cli_session — the pure streaming-json → StreamChunk mapper.
+
+The subprocess runner (``GrokCliSession``) needs the live grok binary, so it is
+not gate-covered; the turn-mapping logic lives in the pure ``_StreamAssembler``
+and is fully exercised here by feeding it parsed events.
+"""
+
+from __future__ import annotations
+
+import json
+
+from roboco.agent_sdk.grok_cli_session import (
+ _classify_failure,
+ _parse_event,
+ _StreamAssembler,
+)
+
+
+def _kinds(chunks: list) -> list[str]:
+ return [c.kind for c in chunks]
+
+
+def test_thought_deltas_coalesce_into_one_thinking_block() -> None:
+ a = _StreamAssembler()
+ out: list = []
+ for piece in ("Let", " me", " think"):
+ out += a.feed({"type": "thought", "data": piece})
+ # Nothing emitted until the answer starts (reasoning shown as one block).
+ assert out == []
+ out += a.feed({"type": "text", "data": "Hello"})
+ assert _kinds(out) == ["thinking", "text"]
+ assert out[0].text == "Let me think"
+ assert out[1].text == "Hello"
+
+
+def test_text_deltas_stream_live() -> None:
+ a = _StreamAssembler()
+ out: list = []
+ for piece in ("a", "b", "c"):
+ out += a.feed({"type": "text", "data": piece})
+ assert _kinds(out) == ["text", "text", "text"]
+ assert "".join(c.text for c in out) == "abc"
+
+
+def test_end_captures_session_id_and_emits_turn_end() -> None:
+ a = _StreamAssembler()
+ a.feed({"type": "text", "data": "hi"})
+ out = a.feed({"type": "end", "sessionId": "sid-9", "stopReason": "EndTurn"})
+ assert _kinds(out) == ["turn_end"]
+ assert a.session_id == "sid-9"
+ assert a.saw_end is True
+ assert out[-1].data["session_id"] == "sid-9"
+
+
+def test_end_flushes_pending_thinking_before_turn_end() -> None:
+ a = _StreamAssembler()
+ a.feed({"type": "thought", "data": "reasoning only"})
+ out = a.feed({"type": "end", "sessionId": "s", "stopReason": "EndTurn"})
+ assert _kinds(out) == ["thinking", "turn_end"]
+
+
+def test_fenced_draft_is_surfaced_as_a_draft_chunk() -> None:
+ a = _StreamAssembler()
+ draft = {"title": "Build X", "objective": "do it"}
+ a.feed({"type": "text", "data": "Here:\n```roboco-draft\n"})
+ a.feed({"type": "text", "data": json.dumps(draft)})
+ a.feed({"type": "text", "data": "\n```\n"})
+ out = a.feed({"type": "end", "sessionId": "s", "stopReason": "EndTurn"})
+ assert "draft" in _kinds(out)
+ draft_chunk = next(c for c in out if c.kind == "draft")
+ assert draft_chunk.data["title"] == "Build X"
+
+
+def test_unknown_event_types_are_ignored() -> None:
+ a = _StreamAssembler()
+ assert a.feed({"type": "tool", "name": "whatever"}) == []
+ assert a.feed({"type": "", "data": "x"}) == []
+
+
+def test_parse_event_is_tolerant() -> None:
+ assert _parse_event('{"type":"text","data":"x"}') == {"type": "text", "data": "x"}
+ assert _parse_event("not json") is None
+ assert _parse_event("[1,2,3]") is None # not a dict
+
+
+def test_classify_failure_detects_rate_limit() -> None:
+ msg = _classify_failure(1, "xAI error: 429 too many requests")
+ assert "rate-limited" in msg.lower()
+
+
+def test_classify_failure_generic_uses_last_stderr_line() -> None:
+ msg = _classify_failure(2, "warming up\nboom: the model exploded")
+ assert "boom: the model exploded" in msg
+ # With no stderr, the exit code is surfaced.
+ assert "exit code 2" in _classify_failure(2, "")
diff --git a/tests/unit/agent_sdk/test_opencode_session.py b/tests/unit/agent_sdk/test_opencode_session.py
deleted file mode 100644
index 29913077..00000000
--- a/tests/unit/agent_sdk/test_opencode_session.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""normalize_opencode_message maps an opencode message reply to panel chunks.
-
-The OpencodeServeSession transport (subprocess + HTTP) is exercised live against
-a real `opencode serve`; the deterministic message→chunk mapping, the
-turn-level error surfacing, and session-id extraction are covered here.
-"""
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-import pytest
-from roboco.agent_sdk.opencode_session import (
- OpencodeServeSession,
- _extract_session_id,
- _message_error,
- normalize_opencode_message,
-)
-
-if TYPE_CHECKING:
- from roboco.agent_sdk.intake_driver import StreamChunk
-
-
-def _kinds(chunks: list[StreamChunk]) -> list[str]:
- return [c.kind for c in chunks]
-
-
-def test_text_part_emits_text_then_turn_end() -> None:
- chunks = normalize_opencode_message({"parts": [{"type": "text", "text": "Hi"}]})
- assert _kinds(chunks) == ["text", "turn_end"]
- assert chunks[0].text == "Hi"
-
-
-def test_reasoning_part_maps_to_thinking() -> None:
- chunks = normalize_opencode_message({"parts": [{"type": "reasoning", "text": "x"}]})
- assert chunks[0].kind == "thinking"
- assert chunks[0].text == "x"
-
-
-def test_tool_part_maps_to_tool_use() -> None:
- chunks = normalize_opencode_message(
- {"parts": [{"type": "tool", "tool": "read", "input": {"path": "x"}}]}
- )
- tool = next(c for c in chunks if c.kind == "tool_use")
- assert tool.tool == "read"
- assert tool.data == {"input": {"path": "x"}}
-
-
-def test_fenced_draft_in_text_becomes_draft_chunk() -> None:
- fenced = '```roboco-draft\n{"title": "Add login"}\n```'
- chunks = normalize_opencode_message({"parts": [{"type": "text", "text": fenced}]})
- draft = next(c for c in chunks if c.kind == "draft")
- assert draft.data["title"] == "Add login"
-
-
-@pytest.mark.asyncio
-async def test_send_on_dead_serve_yields_clear_error(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- # A crashed `opencode serve` must surface a clear error + end the turn, not
- # hang the chat with opaque connection errors while the container zombies.
- sess = OpencodeServeSession()
- monkeypatch.setattr(sess, "_session_id", "ses-1")
- monkeypatch.setattr(sess, "_client", object()) # unused: dead-proc guard wins
- monkeypatch.setattr(sess, "_proc", type("P", (), {"returncode": 1})())
- chunks = [c async for c in sess.send("hi")]
- assert [c.kind for c in chunks] == ["error", "turn_end"]
- assert "exited" in chunks[0].text
-
-
-def test_propose_draft_tool_part_becomes_draft_chunk() -> None:
- # The intake-tools.js propose_draft tool call (its input nested under
- # `draft`) is intercepted into a draft chunk — NOT rendered as a tool_use —
- # so the panel shows the draft card. This is the primary Grok-intake path.
- chunks = normalize_opencode_message(
- {
- "parts": [
- {
- "type": "tool",
- "tool": "propose_draft",
- "input": {"draft": {"title": "Add login", "team": "backend"}},
- }
- ]
- }
- )
- assert "tool_use" not in _kinds(chunks)
- draft = next(c for c in chunks if c.kind == "draft")
- assert draft.data["title"] == "Add login"
- assert draft.data["team"] == "backend"
-
-
-def test_unknown_part_skipped_but_turn_still_ends() -> None:
- chunks = normalize_opencode_message({"parts": [{"type": "mystery", "x": 1}]})
- assert _kinds(chunks) == ["turn_end"]
-
-
-def test_empty_message_yields_only_turn_end() -> None:
- assert _kinds(normalize_opencode_message({"parts": []})) == ["turn_end"]
-
-
-def test_turn_level_error_is_surfaced_not_blank() -> None:
- # A model failure lands in info.error with parts=[]; it must NOT render blank.
- msg = {
- "info": {
- "role": "assistant",
- "error": {
- "name": "APIError",
- "data": {"message": "Incorrect API key provided"},
- },
- },
- "parts": [],
- }
- chunks = normalize_opencode_message(msg)
- assert _kinds(chunks) == ["error", "turn_end"]
- assert "Incorrect API key" in chunks[0].text
-
-
-def test_message_error_extraction() -> None:
- assert _message_error({"info": {"error": {"data": {"message": "boom"}}}}) == "boom"
- assert _message_error({"info": {"error": {"name": "APIError"}}}) == "APIError"
- assert _message_error({"info": {}}) is None
- assert _message_error({"parts": []}) is None
-
-
-def test_extract_session_id_is_tolerant() -> None:
- assert _extract_session_id({"id": "s1"}) == "s1"
- assert _extract_session_id({"sessionID": "s2"}) == "s2"
- assert _extract_session_id({"info": {"id": "s3"}}) == "s3"
- assert _extract_session_id({}) is None
- assert _extract_session_id("nope") is None
diff --git a/tests/unit/llm/providers/test_grok_cli_usage.py b/tests/unit/llm/providers/test_grok_cli_usage.py
index 51dc8107..b8e4e63b 100644
--- a/tests/unit/llm/providers/test_grok_cli_usage.py
+++ b/tests/unit/llm/providers/test_grok_cli_usage.py
@@ -10,6 +10,8 @@ from roboco.llm.providers import grok_cli_usage as gu
if TYPE_CHECKING:
from pathlib import Path
+ import pytest
+
def _write_updates(path: Path, totals: list[int]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -79,7 +81,9 @@ def test_usage_and_cost_prices_total_at_output_rate() -> None:
assert abs(cost - 2.00) < 1e-6 # noqa: PLR2004
-def test_main_writes_usage_file(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
+def test_main_writes_usage_file(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
home = tmp_path / ".grok"
cwd = "/ws/be-dev-1"
sid = "sid-1"
@@ -89,6 +93,7 @@ def test_main_writes_usage_file(tmp_path: Path, monkeypatch) -> None: # type: i
monkeypatch.setattr(gu, "USAGE_OUT_PATH", out)
monkeypatch.setenv("GROK_HOME", str(home))
monkeypatch.setenv("ROBOCO_GROK_RUN_CWD", cwd)
+ monkeypatch.delenv("ROBOCO_GROK_RUN_LOG", raising=False)
monkeypatch.setenv("ROBOCO_AGENT_SESSION_ID", sid)
monkeypatch.setenv("ROBOCO_AGENT_MODEL", "grok-build")
assert gu.main() == 0
@@ -96,3 +101,76 @@ def test_main_writes_usage_file(tmp_path: Path, monkeypatch) -> None: # type: i
assert data["total_tokens"] == 1234 # noqa: PLR2004
assert data["model"] == "grok-build"
assert data["cost_usd"] > 0.0
+
+
+def test_capture_session_usage_writes_running_total(tmp_path: Path) -> None:
+ home = tmp_path / ".grok"
+ cwd = "/ws/intake-1"
+ sid = "sid-x"
+ target = home / "sessions" / "%2Fws%2Fintake-1" / sid
+ _write_updates(target / "updates.jsonl", [100, 900, 500])
+ out = tmp_path / "usage.json"
+ tokens = gu.capture_session_usage(
+ cwd=cwd, session_id=sid, model="grok-build", out_path=out, grok_home=home
+ )
+ assert tokens == 900 # noqa: PLR2004 — the running max is the chat total
+ data = json.loads(out.read_text())
+ assert data["total_tokens"] == 900 # noqa: PLR2004
+ assert data["cost_usd"] > 0.0
+
+
+def test_capture_session_usage_zero_when_session_absent(tmp_path: Path) -> None:
+ out = tmp_path / "usage.json"
+ tokens = gu.capture_session_usage(
+ cwd="/ws/x",
+ session_id="missing",
+ model="grok-build",
+ out_path=out,
+ grok_home=tmp_path / ".grok",
+ )
+ assert tokens == 0
+ # A zero session still writes a usage file (a real zero-cost run).
+ assert json.loads(out.read_text())["total_tokens"] == 0
+
+
+def test_session_id_from_run_log_reads_the_real_id(tmp_path: Path) -> None:
+ log = tmp_path / "run.json"
+ log.write_text(
+ json.dumps({"text": "ok", "sessionId": "019edd9d-real", "stopReason": "End"}),
+ encoding="utf-8",
+ )
+ assert gu.session_id_from_run_log(log) == "019edd9d-real"
+
+
+def test_session_id_from_run_log_none_for_bad_log(tmp_path: Path) -> None:
+ assert gu.session_id_from_run_log(tmp_path / "absent.json") is None
+ bad = tmp_path / "bad.json"
+ bad.write_text("not json", encoding="utf-8")
+ assert gu.session_id_from_run_log(bad) is None
+ idless = tmp_path / "idless.json"
+ idless.write_text(json.dumps({"text": "ok"}), encoding="utf-8")
+ assert gu.session_id_from_run_log(idless) is None
+
+
+def test_main_prefers_run_log_session_id(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # grok ignores a requested id, so the real id comes from the run log — it must
+ # win over the ROBOCO_AGENT_SESSION_ID fallback (which points at no store).
+ home = tmp_path / ".grok"
+ cwd = "/ws/be-dev-1"
+ real_sid = "real-sid"
+ _write_updates(
+ home / "sessions" / "%2Fws%2Fbe-dev-1" / real_sid / "updates.jsonl", [777]
+ )
+ run_log = tmp_path / "run.json"
+ run_log.write_text(json.dumps({"sessionId": real_sid}), encoding="utf-8")
+ out = tmp_path / "usage.json"
+ monkeypatch.setattr(gu, "USAGE_OUT_PATH", out)
+ monkeypatch.setenv("GROK_HOME", str(home))
+ monkeypatch.setenv("ROBOCO_GROK_RUN_CWD", cwd)
+ monkeypatch.setenv("ROBOCO_GROK_RUN_LOG", str(run_log))
+ monkeypatch.setenv("ROBOCO_AGENT_SESSION_ID", "ignored-fallback")
+ monkeypatch.setenv("ROBOCO_AGENT_MODEL", "grok-build")
+ assert gu.main() == 0
+ assert json.loads(out.read_text())["total_tokens"] == 777 # noqa: PLR2004
diff --git a/tests/unit/llm/test_opencode_config.py b/tests/unit/llm/test_opencode_config.py
deleted file mode 100644
index 47c4b6d0..00000000
--- a/tests/unit/llm/test_opencode_config.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""Tests for the Grok opencode.json generator (RoboCo MCP -> opencode config)."""
-
-from __future__ import annotations
-
-from roboco.llm.providers.opencode_config import (
- OpencodeGuards,
- build_opencode_config,
- translate_mcp_servers,
-)
-
-_MODEL = "grok-build-0.1"
-
-_MCP = {
- "mcpServers": {
- "roboco-flow": {
- "command": "uv",
- "args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"],
- "env": {
- "ROBOCO_AGENT_ID": "uuid-1",
- "UV_PROJECT_ENVIRONMENT": "/app/.venv",
- },
- },
- "roboco-do": {
- "command": "uv",
- "args": ["run", "--no-sync", "python", "-m", "roboco.mcp.do_server"],
- "env": {"ROBOCO_AGENT_ID": "uuid-1"},
- },
- }
-}
-
-
-def test_translate_mcp_servers_shape() -> None:
- out = translate_mcp_servers(_MCP)
- flow = out["roboco-flow"]
- assert flow["type"] == "local"
- assert flow["enabled"] is True
- # command + args collapse into a single command array (opencode shape).
- assert flow["command"] == [
- "uv",
- "run",
- "--no-sync",
- "python",
- "-m",
- "roboco.mcp.flow_server",
- ]
- # env -> environment (opencode key).
- assert flow["environment"]["ROBOCO_AGENT_ID"] == "uuid-1"
- assert "env" not in flow
- assert set(out) == {"roboco-flow", "roboco-do"}
-
-
-def test_translate_mcp_servers_empty() -> None:
- assert translate_mcp_servers({}) == {}
- assert translate_mcp_servers({"mcpServers": {}}) == {}
-
-
-def test_translate_mcp_servers_omits_environment_when_no_env() -> None:
- out = translate_mcp_servers(
- {"mcpServers": {"x": {"command": "uv", "args": ["run"]}}}
- )
- assert "environment" not in out["x"]
- assert out["x"]["command"] == ["uv", "run"]
-
-
-def test_build_opencode_config_emits_no_provider_block() -> None:
- cfg = build_opencode_config(
- _MCP,
- _MODEL,
- instruction_paths=["/app/system-prompt.md"],
- )
- # CRITICAL: NO provider block. ANY provider.xai block breaks plugin-tool
- # registration on opencode 1.17.8 (verified live). The built-in xai provider
- # drives the model; the key reaches it via the XAI_API_KEY env var.
- assert "provider" not in cfg
- # Top-level model selector is "/".
- assert cfg["model"] == "xai/grok-build-0.1"
- # Gateway servers carried through.
- assert "roboco-flow" in cfg["mcp"]
- assert cfg["instructions"] == ["/app/system-prompt.md"]
-
-
-def test_build_opencode_config_has_no_plugin_array() -> None:
- # opencode 1.17.8 ignores config `plugin:`-array absolute paths for
- # registration; plugins live in the auto-discovery dir, baked into the images.
- cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
- assert "plugin" not in cfg
-
-
-def test_build_opencode_config_edit_permission_is_tunable() -> None:
- # Read-only roles (qa / pr_reviewer / auditor / PMs / board) get edit=deny so
- # a Grok agent can't write code on a role that must never touch the tree.
- cfg = build_opencode_config(
- {},
- _MODEL,
- instruction_paths=[],
- guards=OpencodeGuards(edit_permission="deny"),
- )
- assert cfg["permission"]["edit"] == "deny"
-
-
-def test_build_opencode_config_bash_permission_is_tunable() -> None:
- cfg = build_opencode_config(
- {},
- _MODEL,
- instruction_paths=[],
- guards=OpencodeGuards(bash_permission="deny"),
- )
- assert cfg["permission"]["bash"] == "deny"
- assert cfg["permission"]["edit"] == "allow"
-
-
-def test_build_opencode_config_allows_external_directory_by_default() -> None:
- # opencode auto-denies an "ask" external-dir read in headless mode (the
- # pr-reviewer couldn't read a diff it wrote to /tmp); default "allow".
- cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
- assert cfg["permission"]["external_directory"] == "allow"
-
-
-def test_build_opencode_config_external_directory_is_tunable() -> None:
- cfg = build_opencode_config(
- {},
- _MODEL,
- instruction_paths=[],
- guards=OpencodeGuards(external_directory_permission="deny"),
- )
- assert cfg["permission"]["external_directory"] == "deny"
-
-
-def test_build_opencode_config_disables_subagent_task_tool_by_default() -> None:
- # The subagent `task` tool must be hard-disabled: a RoboCo role never uses
- # opencode-internal subagents, and one spawned on grok-build-0.1 hung the run.
- cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
- assert cfg["tools"] == {"task": False}
-
-
-def test_build_opencode_config_subagents_can_be_re_enabled() -> None:
- cfg = build_opencode_config(
- _MCP,
- _MODEL,
- instruction_paths=[],
- guards=OpencodeGuards(disable_subagents=False),
- )
- assert "tools" not in cfg
diff --git a/tests/unit/llm/test_opencode_usage.py b/tests/unit/llm/test_opencode_usage.py
deleted file mode 100644
index d2f01ef2..00000000
--- a/tests/unit/llm/test_opencode_usage.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""Tests for opencode usage capture (reading the opencode SQLite session table).
-
-The fixture DB mirrors the real opencode v1.x ``session`` table columns observed
-from a local run (cost + tokens_input/output/reasoning/cache_read/cache_write).
-"""
-
-from __future__ import annotations
-
-import sqlite3
-from typing import TYPE_CHECKING
-
-from roboco.llm.providers.opencode_usage import (
- cost_for_session,
- read_session_usage,
-)
-
-if TYPE_CHECKING:
- from pathlib import Path
-
-_M = 1_000_000
-_TOL = 1e-4
-_ZERO_COST = 0.0
-
-# Single-session fixture: input, output, reasoning, cache_read, cache_write.
-_IN, _OUT, _REASON, _CREAD, _CWRITE = 100, 50, 10, 20, 5
-# Second session for the summation test.
-_S2_IN, _S2_OUT, _S2_CREAD = 200, 70, 10
-# grok-build-0.1: 1M input ($1.00) + 1M output ($2.00) = $3.00.
-_GROK_COST_1M_1M = 3.00
-
-# A REAL grok-build-0.1 session row observed from a live opencode run. Our
-# pricing must reproduce opencode's own stored `cost` (= xAI authoritative).
-_REAL_IN, _REAL_OUT, _REAL_REASON, _REAL_CREAD = 6120, 1, 226, 1856
-_REAL_COST = 0.0069452
-
-
-def _make_db(
- path: Path, rows: list[tuple[str, int, int, int, int, int, float]]
-) -> None:
- con = sqlite3.connect(path)
- con.execute(
- """
- CREATE TABLE session (
- id text PRIMARY KEY,
- tokens_input integer DEFAULT 0 NOT NULL,
- tokens_output integer DEFAULT 0 NOT NULL,
- tokens_reasoning integer DEFAULT 0 NOT NULL,
- tokens_cache_read integer DEFAULT 0 NOT NULL,
- tokens_cache_write integer DEFAULT 0 NOT NULL,
- cost real DEFAULT 0 NOT NULL
- )
- """
- )
- con.executemany(
- "INSERT INTO session "
- "(id, tokens_input, tokens_output, tokens_reasoning, "
- "tokens_cache_read, tokens_cache_write, cost) "
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
- rows,
- )
- con.commit()
- con.close()
-
-
-def test_read_missing_db_returns_none(tmp_path: Path) -> None:
- assert read_session_usage(tmp_path / "nope.db") is None
-
-
-def test_read_single_session(tmp_path: Path) -> None:
- db = tmp_path / "opencode.db"
- # (id, input, output, reasoning, cache_read, cache_write, cost)
- _make_db(db, [("s1", _IN, _OUT, _REASON, _CREAD, _CWRITE, 0.0007)])
- usage = read_session_usage(db, session_id="s1")
- assert usage is not None
- assert usage.tokens_input == _IN
- assert usage.tokens_output == _OUT
- assert usage.tokens_cache_read == _CREAD
- assert usage.tokens_cache_write == _CWRITE
- assert usage.tokens_reasoning == _REASON
-
-
-def test_read_sums_all_sessions_when_no_id(tmp_path: Path) -> None:
- db = tmp_path / "opencode.db"
- _make_db(
- db,
- [
- ("s1", _IN, _OUT, 0, 0, 0, 0.0),
- ("s2", _S2_IN, _S2_OUT, 0, _S2_CREAD, 0, 0.0),
- ],
- )
- usage = read_session_usage(db)
- assert usage is not None
- assert usage.tokens_input == _IN + _S2_IN
- assert usage.tokens_output == _OUT + _S2_OUT
- assert usage.tokens_cache_read == _S2_CREAD
-
-
-def test_read_empty_table_returns_none(tmp_path: Path) -> None:
- db = tmp_path / "opencode.db"
- _make_db(db, [])
- assert read_session_usage(db) is None
-
-
-def test_cost_for_session_uses_roboco_pricing(tmp_path: Path) -> None:
- db = tmp_path / "opencode.db"
- # 1M input + 1M output for grok-build-0.1 → our $3.00, not opencode's 99.0.
- _make_db(db, [("s1", _M, _M, 0, 0, 0, 99.0)])
- usage, cost = cost_for_session("grok-build-0.1", db, session_id="s1")
- assert usage is not None
- assert abs(cost - _GROK_COST_1M_1M) < _TOL
-
-
-def test_cost_for_session_missing_db(tmp_path: Path) -> None:
- usage, cost = cost_for_session("grok-build-0.1", tmp_path / "nope.db")
- assert usage is None
- assert cost == _ZERO_COST
-
-
-def test_cost_reproduces_opencode_authoritative_cost(tmp_path: Path) -> None:
- """Real observed row: our pricing must match opencode's stored USD cost.
-
- Proves the column semantics (non-cached input disjoint from cache_read;
- reasoning separate, billed at output rate).
- """
- db = tmp_path / "opencode.db"
- # (id, input, output, reasoning, cache_read, cache_write, cost)
- _make_db(
- db,
- [("real", _REAL_IN, _REAL_OUT, _REAL_REASON, _REAL_CREAD, 0, _REAL_COST)],
- )
- usage, cost = cost_for_session("grok-build-0.1", db, session_id="real")
- assert usage is not None
- assert abs(cost - _REAL_COST) < _TOL
- assert abs(cost - usage.opencode_cost) < _TOL
diff --git a/tests/unit/llm/test_providers.py b/tests/unit/llm/test_providers.py
index bbd682de..93d2dd9f 100644
--- a/tests/unit/llm/test_providers.py
+++ b/tests/unit/llm/test_providers.py
@@ -29,14 +29,10 @@ from roboco.models.runtime import OrchestratorAgentConfig
@pytest.fixture(autouse=True)
-def _isolate_grok_auth(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> Path:
+def _isolate_grok_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point GROK_AUTH_HOST_PATH at a fresh tmp dir so tests never mount the real
~/.grok. Tests that exercise the auth mount create ``auth.json`` themselves."""
- monkeypatch.setattr(
- "roboco.llm.providers.grok.GROK_AUTH_HOST_PATH", str(tmp_path)
- )
+ monkeypatch.setattr("roboco.llm.providers.grok.GROK_AUTH_HOST_PATH", str(tmp_path))
return tmp_path
@@ -81,7 +77,7 @@ class _FakeHost:
async def _remove_container(self, container_name: str) -> None:
self.removed.append(container_name)
- def _ensure_opencode_data_dir(self, agent_id: str) -> None:
+ def _ensure_grok_usage_dir(self, agent_id: str) -> None:
self.data_dirs_ensured.append(agent_id)
def _resolve_host_paths(
@@ -92,7 +88,7 @@ class _FakeHost:
if config.mcp_config_path
else None,
"settings": str(agent_settings_path) if agent_settings_path else None,
- "opencode": f"/host/data/opencode/{config.agent_id}",
+ "grok_usage": f"/host/data/grok-usage/{config.agent_id}",
}
def _build_mount_args(
@@ -218,11 +214,12 @@ async def test_grok_spawn_wires_gateway_env_and_image_last() -> None:
assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd
assert "ROBOCO_AGENT_ID=be-dev-1" in cmd # renderer computes per-role flags
assert "ROBOCO_AGENT_MODEL=grok-build" in cmd
- # Fixed session id so usage capture can locate the run's session store.
- assert "ROBOCO_AGENT_SESSION_ID=sess-1" in cmd
+ # No session id is injected: grok ignores a requested id, so the entrypoint
+ # reads the real one back from the run log for usage capture.
+ assert not any(c.startswith("ROBOCO_AGENT_SESSION_ID=") for c in cmd)
# Usage capture: per-agent data dir mounted + the entrypoint's usage file.
assert host.data_dirs_ensured == ["be-dev-1"]
- assert "/host/data/opencode/be-dev-1:/home/agent/.grok-usage" in cmd
+ assert "/host/data/grok-usage/be-dev-1:/home/agent/.grok-usage" in cmd
assert "ROBOCO_GROK_USAGE_FILE=/home/agent/.grok-usage/usage.json" in cmd
# Identity wiring from the shared host helpers is present.
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
diff --git a/tests/unit/mcp_servers/test_intake_server.py b/tests/unit/mcp_servers/test_intake_server.py
new file mode 100644
index 00000000..4c93e0ef
--- /dev/null
+++ b/tests/unit/mcp_servers/test_intake_server.py
@@ -0,0 +1,92 @@
+"""roboco-intake MCP server — propose_draft delivers the draft to the relay."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+import pytest
+from roboco.mcp import intake_server
+
+
+def _client(handler: Any) -> httpx.AsyncClient:
+ return httpx.AsyncClient(transport=httpx.MockTransport(handler))
+
+
+@pytest.mark.asyncio
+async def test_post_draft_posts_to_the_relay(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("ROBOCO_API_URL", "http://orch:8000")
+ seen: dict[str, Any] = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ seen["json"] = __import__("json").loads(request.content)
+ return httpx.Response(200, json={"ok": True})
+
+ async with _client(handler) as client:
+ result = await intake_server.post_draft(
+ "sess-1", {"title": "Build X"}, client=client
+ )
+
+ assert result == {"ok": True}
+ assert seen["url"] == "http://orch:8000/api/prompter/live/sess-1/events"
+ assert seen["json"]["kind"] == "draft"
+ assert seen["json"]["tool"] == "propose_draft"
+ assert seen["json"]["data"] == {"title": "Build X"}
+
+
+@pytest.mark.asyncio
+async def test_post_draft_reports_http_error() -> None:
+ def handler(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(503)
+
+ async with _client(handler) as client:
+ result = await intake_server.post_draft("s", {}, client=client)
+ assert result == {"error": "http_503"}
+
+
+@pytest.mark.asyncio
+async def test_post_draft_reports_request_failure() -> None:
+ def handler(_request: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectError("boom")
+
+ async with _client(handler) as client:
+ result = await intake_server.post_draft("s", {}, client=client)
+ assert result["error"] == "request_failed"
+ assert "boom" in result["detail"]
+
+
+@pytest.mark.asyncio
+async def test_propose_draft_requires_a_live_session(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("ROBOCO_PROMPTER_SESSION_ID", raising=False)
+ msg = await intake_server.propose_draft({"title": "X"})
+ assert "No live session id" in msg
+
+
+@pytest.mark.asyncio
+async def test_propose_draft_acks_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
+
+ async def _ok(_sid: str, _draft: dict[str, Any]) -> dict[str, Any]:
+ return {"ok": True}
+
+ monkeypatch.setattr(intake_server, "post_draft", _ok)
+ msg = await intake_server.propose_draft({"title": "X"})
+ assert "Draft submitted" in msg
+
+
+@pytest.mark.asyncio
+async def test_propose_draft_reports_relay_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
+
+ async def _fail(_sid: str, _draft: dict[str, Any]) -> dict[str, Any]:
+ return {"error": "http_503"}
+
+ monkeypatch.setattr(intake_server, "post_draft", _fail)
+ msg = await intake_server.propose_draft({"title": "X"})
+ assert "Could not submit the draft" in msg
+ assert "http_503" in msg
diff --git a/tests/unit/mcp_servers/test_secretary_server.py b/tests/unit/mcp_servers/test_secretary_server.py
new file mode 100644
index 00000000..24deb511
--- /dev/null
+++ b/tests/unit/mcp_servers/test_secretary_server.py
@@ -0,0 +1,75 @@
+"""roboco-secretary MCP server — tools wrap the shared backend helpers as JSON.
+
+The backend-calling logic (``secretary_driver._do_*``) is covered by the secretary
+driver tests; here we only assert the MCP wrappers forward the right args and
+return the backend result as a JSON string the model reads back.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import pytest
+from roboco.mcp import secretary_server
+
+
+@pytest.mark.asyncio
+async def test_read_company_state_returns_json(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _state() -> dict[str, Any]:
+ return {"charter": "ship it", "tasks": {"pending": 3}}
+
+ monkeypatch.setattr(secretary_server, "_do_read_state", _state)
+ out = await secretary_server.read_company_state()
+ assert json.loads(out) == {"charter": "ship it", "tasks": {"pending": 3}}
+
+
+@pytest.mark.asyncio
+async def test_read_task_forwards_the_id(monkeypatch: pytest.MonkeyPatch) -> None:
+ seen: dict[str, Any] = {}
+
+ async def _task(task_id: str) -> dict[str, Any]:
+ seen["id"] = task_id
+ return {"id": task_id, "title": "T"}
+
+ monkeypatch.setattr(secretary_server, "_do_read_task", _task)
+ out = await secretary_server.read_task("task-9")
+ assert seen["id"] == "task-9"
+ assert json.loads(out)["title"] == "T"
+
+
+@pytest.mark.asyncio
+async def test_submit_directive_forwards_kind_and_payload(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ seen: dict[str, Any] = {}
+
+ async def _submit(kind: str, payload: dict[str, Any]) -> dict[str, Any]:
+ seen["kind"] = kind
+ seen["payload"] = payload
+ return {"queued": True}
+
+ monkeypatch.setattr(secretary_server, "_do_submit_directive", _submit)
+ out = await secretary_server.submit_directive(
+ "relay_message", {"channel": "announcements", "text": "hi"}
+ )
+ assert seen["kind"] == "relay_message"
+ assert seen["payload"] == {"channel": "announcements", "text": "hi"}
+ assert json.loads(out) == {"queued": True}
+
+
+@pytest.mark.asyncio
+async def test_submit_directive_tolerates_missing_payload(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ seen: dict[str, Any] = {}
+
+ async def _submit(_kind: str, payload: dict[str, Any]) -> dict[str, Any]:
+ seen["payload"] = payload
+ return {"ok": True}
+
+ monkeypatch.setattr(secretary_server, "_do_submit_directive", _submit)
+ await secretary_server.submit_directive("announce", None)
+ assert seen["payload"] == {}
diff --git a/tests/unit/runtime/test_grok_cost_budget.py b/tests/unit/runtime/test_grok_cost_budget.py
index ba1f44cc..f40aaa45 100644
--- a/tests/unit/runtime/test_grok_cost_budget.py
+++ b/tests/unit/runtime/test_grok_cost_budget.py
@@ -1,10 +1,11 @@
"""GROK cost budget kill-switch: kill a live container over the cost ceiling.
-opencode exposes no usage hook to a plugin, so the budget kill-switch lives in
-the orchestrator: it reads each live GROK container's cumulative opencode cost
-and kills + evicts it past ROBOCO_GROK_MAX_COST_USD (also catching runaway-loop
-token burn). The cost computation itself is covered in opencode_usage tests; here
-cost_for_session is stubbed so the kill DECISION is exercised deterministically.
+The grok CLI exposes no live usage hook, so the budget kill-switch lives in the
+orchestrator: it reads each live GROK container's captured cost (from its
+usage.json, via ``_grok_cost_usd``) and kills + evicts it past
+ROBOCO_GROK_MAX_COST_USD (also catching runaway-loop token burn). The usage.json
+read is covered in the grok usage tests; here ``_grok_cost_usd`` is stubbed so the
+kill DECISION is exercised deterministically.
"""
from __future__ import annotations
@@ -15,22 +16,32 @@ import pytest
from roboco.models.runtime import AgentInstance
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
-_COST_FN = "roboco.llm.providers.opencode_usage.cost_for_session"
-
def _grok_instance(provider_type: str = "grok") -> AgentInstance:
- cfg = type("C", (), {"provider_type": provider_type, "model": "grok-build-0.1"})()
+ cfg = type("C", (), {"provider_type": provider_type, "model": "grok-build"})()
return AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE, config=cfg)
+def _orch(
+ monkeypatch: pytest.MonkeyPatch,
+ *,
+ cap: float,
+ cost: float,
+ provider_type: str = "grok",
+) -> tuple[AgentOrchestrator, AsyncMock]:
+ """A bare orchestrator with the cost reader + container removal stubbed."""
+ orch = AgentOrchestrator.__new__(AgentOrchestrator)
+ orch._grok_max_cost_usd = cap
+ orch._instances = {"be-dev-1": _grok_instance(provider_type)}
+ monkeypatch.setattr(orch, "_grok_cost_usd", lambda _agent_id: cost)
+ remove_mock = AsyncMock()
+ monkeypatch.setattr(orch, "_remove_container", remove_mock)
+ return orch, remove_mock
+
+
@pytest.mark.asyncio
async def test_cost_over_cap_kills_and_evicts(monkeypatch: pytest.MonkeyPatch) -> None:
- orch = AgentOrchestrator.__new__(AgentOrchestrator)
- orch._grok_max_cost_usd = 5.0
- orch._instances = {"be-dev-1": _grok_instance()}
- remove_mock = AsyncMock()
- monkeypatch.setattr(orch, "_remove_container", remove_mock)
- monkeypatch.setattr(_COST_FN, lambda *_a, **_k: (None, 7.5))
+ orch, remove_mock = _orch(monkeypatch, cap=5.0, cost=7.5)
await orch._enforce_grok_cost_budget()
@@ -40,12 +51,7 @@ async def test_cost_over_cap_kills_and_evicts(monkeypatch: pytest.MonkeyPatch) -
@pytest.mark.asyncio
async def test_cost_under_cap_spares(monkeypatch: pytest.MonkeyPatch) -> None:
- orch = AgentOrchestrator.__new__(AgentOrchestrator)
- orch._grok_max_cost_usd = 5.0
- orch._instances = {"be-dev-1": _grok_instance()}
- remove_mock = AsyncMock()
- monkeypatch.setattr(orch, "_remove_container", remove_mock)
- monkeypatch.setattr(_COST_FN, lambda *_a, **_k: (None, 1.0))
+ orch, remove_mock = _orch(monkeypatch, cap=5.0, cost=1.0)
await orch._enforce_grok_cost_budget()
@@ -55,12 +61,7 @@ async def test_cost_under_cap_spares(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.asyncio
async def test_cap_zero_disables_the_sweep(monkeypatch: pytest.MonkeyPatch) -> None:
- orch = AgentOrchestrator.__new__(AgentOrchestrator)
- orch._grok_max_cost_usd = 0.0
- orch._instances = {"be-dev-1": _grok_instance()}
- remove_mock = AsyncMock()
- monkeypatch.setattr(orch, "_remove_container", remove_mock)
- monkeypatch.setattr(_COST_FN, lambda *_a, **_k: (None, 999.0))
+ orch, remove_mock = _orch(monkeypatch, cap=0.0, cost=999.0)
await orch._enforce_grok_cost_budget()
@@ -70,12 +71,9 @@ async def test_cap_zero_disables_the_sweep(monkeypatch: pytest.MonkeyPatch) -> N
@pytest.mark.asyncio
async def test_non_grok_container_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
- orch = AgentOrchestrator.__new__(AgentOrchestrator)
- orch._grok_max_cost_usd = 5.0
- orch._instances = {"be-dev-1": _grok_instance(provider_type="anthropic")}
- remove_mock = AsyncMock()
- monkeypatch.setattr(orch, "_remove_container", remove_mock)
- monkeypatch.setattr(_COST_FN, lambda *_a, **_k: (None, 999.0))
+ orch, remove_mock = _orch(
+ monkeypatch, cap=5.0, cost=999.0, provider_type="anthropic"
+ )
await orch._enforce_grok_cost_budget()
diff --git a/tests/unit/runtime/test_grok_usage_finalize.py b/tests/unit/runtime/test_grok_usage_finalize.py
index 368ee001..1bebb033 100644
--- a/tests/unit/runtime/test_grok_usage_finalize.py
+++ b/tests/unit/runtime/test_grok_usage_finalize.py
@@ -1,13 +1,15 @@
-"""GROK agents capture token usage/cost from their opencode SQLite store.
+"""GROK agents capture token usage/cost from their captured ``usage.json``.
-A Grok agent runs opencode — no SDK /usage/status server and no Claude
-transcript — so finalize must read opencode.db (mounted into the orchestrator)
-instead. Reasoning folds into output (it bills at the output rate).
+A Grok agent runs the grok CLI — no SDK /usage/status server and no Claude
+transcript — so finalize reads the ``usage.json`` the entrypoint / interactive
+driver wrote to the per-agent data dir (mounted into the orchestrator). grok
+reports a single cumulative total with no input/output split, so it folds into
+output (it bills at the output rate).
"""
from __future__ import annotations
-import sqlite3
+import json
from typing import TYPE_CHECKING
import pytest
@@ -18,82 +20,58 @@ if TYPE_CHECKING:
from pathlib import Path
-def _make_db(path: Path, cols: dict[str, float]) -> None:
- con = sqlite3.connect(path)
- con.execute(
- "CREATE TABLE session (id TEXT, tokens_input INT, tokens_output INT, "
- "tokens_cache_read INT, tokens_cache_write INT, tokens_reasoning INT, "
- "cost REAL)"
- )
- con.execute(
- "INSERT INTO session (id, tokens_input, tokens_output, tokens_cache_read, "
- "tokens_cache_write, tokens_reasoning, cost) VALUES (?,?,?,?,?,?,?)",
- (
- "s1",
- cols["tokens_input"],
- cols["tokens_output"],
- cols["tokens_cache_read"],
- cols["tokens_cache_write"],
- cols["tokens_reasoning"],
- cols["cost"],
+def _write_usage(path: Path, total_tokens: int, cost_usd: float) -> None:
+ path.write_text(
+ json.dumps(
+ {"model": "grok-build", "total_tokens": total_tokens, "cost_usd": cost_usd}
),
+ encoding="utf-8",
)
- con.commit()
- con.close()
-def test_grok_usage_folds_reasoning_into_output(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
- db = tmp_path / "opencode.db"
- _make_db(
- db,
- {
- "tokens_input": 100,
- "tokens_output": 50,
- "tokens_reasoning": 30,
- "tokens_cache_read": 10,
- "tokens_cache_write": 5,
- "cost": 0.02,
- },
- )
- orch = AgentOrchestrator.__new__(AgentOrchestrator)
- monkeypatch.setattr(orch, "_opencode_db_path", lambda _aid: str(db))
-
- # reasoning (30) folded into output (50) → 80; bills at the output rate.
- assert orch._grok_usage_from_opencode("be-dev-1") == (100, 80, 10, 5)
-
-
-def test_grok_usage_zero_when_store_missing(
+def test_grok_usage_folds_total_into_output(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
+ usage = tmp_path / "usage.json"
+ _write_usage(usage, total_tokens=180, cost_usd=0.02)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(
- orch, "_opencode_db_path", lambda _aid: str(tmp_path / "absent.db")
+ orch, "_grok_usage_json", lambda _aid: json.loads(usage.read_text())
)
- assert orch._grok_usage_from_opencode("be-dev-1") == (0, 0, 0, 0)
+
+ # The whole total folds into output (no input/output split from the CLI).
+ assert orch._grok_usage_tokens("be-dev-1") == (0, 180, 0, 0)
+
+
+def test_grok_usage_zero_when_store_missing(monkeypatch: pytest.MonkeyPatch) -> None:
+ orch = AgentOrchestrator.__new__(AgentOrchestrator)
+ monkeypatch.setattr(orch, "_grok_usage_json", lambda _aid: None)
+ assert orch._grok_usage_tokens("be-dev-1") == (0, 0, 0, 0)
+
+
+def test_grok_cost_read_from_usage_json(monkeypatch: pytest.MonkeyPatch) -> None:
+ captured_cost = 3.25
+ orch = AgentOrchestrator.__new__(AgentOrchestrator)
+ monkeypatch.setattr(
+ orch,
+ "_grok_usage_json",
+ lambda _aid: {"cost_usd": captured_cost, "total_tokens": 9},
+ )
+ assert orch._grok_cost_usd("be-dev-1") == captured_cost
+ monkeypatch.setattr(orch, "_grok_usage_json", lambda _aid: None)
+ assert orch._grok_cost_usd("be-dev-1") == 0.0
@pytest.mark.asyncio
-async def test_resolve_final_usage_routes_grok_to_opencode(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+async def test_resolve_final_usage_routes_grok_to_usage_json(
+ monkeypatch: pytest.MonkeyPatch,
) -> None:
- db = tmp_path / "opencode.db"
- _make_db(
- db,
- {
- "tokens_input": 7,
- "tokens_output": 3,
- "tokens_reasoning": 2,
- "tokens_cache_read": 0,
- "tokens_cache_write": 0,
- "cost": 0.01,
- },
- )
orch = AgentOrchestrator.__new__(AgentOrchestrator)
- monkeypatch.setattr(orch, "_opencode_db_path", lambda _aid: str(db))
+ monkeypatch.setattr(
+ orch, "_grok_usage_json", lambda _aid: {"total_tokens": 12, "cost_usd": 0.01}
+ )
cfg = type("C", (), {"provider_type": "grok"})()
orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)}
- # No SDK fetch / transcript read for GROK — usage comes from opencode.db.
- assert await orch._resolve_final_token_usage("be-dev-1") == (7, 5, 0, 0)
+ # No SDK fetch / transcript read for GROK — usage comes from usage.json.
+ assert await orch._resolve_final_token_usage("be-dev-1") == (0, 12, 0, 0)
diff --git a/tests/unit/runtime/test_interactive_grok_spawn.py b/tests/unit/runtime/test_interactive_grok_spawn.py
index 72d6b2b0..6424d5a7 100644
--- a/tests/unit/runtime/test_interactive_grok_spawn.py
+++ b/tests/unit/runtime/test_interactive_grok_spawn.py
@@ -1,12 +1,16 @@
-"""Interactive intake/secretary builders fork a GROK route onto opencode.
+"""Interactive intake/secretary builders fork a GROK route onto the grok CLI.
-A GROK route swaps the Claude SDK-driver image for the opencode-serve image and
-the ANTHROPIC_* env for XAI_* + the opencode store mount; every other
-provider keeps the Claude path's ANTHROPIC_* behaviour.
+A GROK route swaps the Claude SDK-driver image for the grok-CLI prompter/secretary
+image and the ANTHROPIC_* env for the subscription auth mount + the per-agent
+usage mount (no metered xAI key, no permission env — the driver computes the grok
+permission flags). Every other provider keeps the Claude path's ANTHROPIC_*.
"""
from __future__ import annotations
+from typing import TYPE_CHECKING
+
+from roboco.llm.providers import grok as grok_provider
from roboco.runtime.orchestrator import (
GROK_PROMPTER_IMAGE,
GROK_SECRETARY_IMAGE,
@@ -15,20 +19,21 @@ from roboco.runtime.orchestrator import (
_SecretaryRunSpec,
)
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ import pytest
+
_HOSTS: dict[str, str | None] = {
"claude": "/h/.claude",
"prompt": "/h/p.md",
"workspaces": "/h/ws",
- "opencode": "/h/oc/intake-1",
+ "grok_usage": "/h/gu/intake-1",
}
def _intake_spec(
- provider_type: str,
- *,
- base_url: str | None,
- token: str | None,
- grok_variant: str | None = None,
+ provider_type: str, *, base_url: str | None, token: str | None
) -> _IntakeRunSpec:
return _IntakeRunSpec(
container_name="roboco-agent-intake-1",
@@ -38,57 +43,46 @@ def _intake_spec(
hosts=_HOSTS,
session_id="sess-1",
cwd="/data/workspace",
- cli_model="grok-build-0.1",
+ cli_model="grok-build",
api_url="http://roboco-orchestrator:8000",
provider_base_url=base_url,
provider_auth_token=token,
provider_type=provider_type,
- model="grok-build-0.1",
- grok_variant=grok_variant,
+ model="grok-build",
)
-def test_intake_grok_uses_xai_env_and_opencode_mount() -> None:
- cmd = AgentOrchestrator._build_intake_run_cmd(
- _intake_spec(
- "grok",
- base_url="https://api.x.ai/v1",
- token="xai-key",
- grok_variant="minimal",
- )
- )
- assert "XAI_API_KEY=xai-key" in cmd
- assert "XAI_BASE_URL=https://api.x.ai/v1" in cmd
- assert "ROBOCO_AGENT_MODEL=grok-build-0.1" in cmd
- assert "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md" in cmd
- assert "/h/oc/intake-1:/home/agent/.local/share/opencode" in cmd
- # Per-role reasoning effort reaches the container for the serve driver.
- assert "ROBOCO_GROK_VARIANT=minimal" in cmd
- assert cmd[-1] == GROK_PROMPTER_IMAGE
- # The xAI endpoint is never mislabelled as Anthropic.
- assert not any(c.startswith("ANTHROPIC_") for c in cmd)
- # Intake is read-only (no code edits, no shell) but reads sibling product
- # repos OUTSIDE its cwd, so it keeps external-directory reads.
- assert "ROBOCO_GROK_EDIT_PERMISSION=deny" in cmd
- assert "ROBOCO_GROK_BASH_PERMISSION=deny" in cmd
- assert "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=allow" in cmd
-
-
-def test_intake_anthropic_omits_grok_permission_env() -> None:
- # The opencode permission env is a GROK-only contract; the Claude path never
- # sets it (it gates tools via the SDK can_use_tool allowlist instead).
- cmd = AgentOrchestrator._build_intake_run_cmd(
- _intake_spec("anthropic", base_url="https://api.anthropic.com", token="sk-ant")
- )
- assert not any(c.startswith("ROBOCO_GROK_EDIT_PERMISSION=") for c in cmd)
- assert not any(c.startswith("ROBOCO_GROK_BASH_PERMISSION=") for c in cmd)
-
-
-def test_intake_grok_omits_variant_when_unset() -> None:
+def test_intake_grok_uses_grok_cli_usage_mount_and_env() -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(
_intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
)
- assert not any(c.startswith("ROBOCO_GROK_VARIANT=") for c in cmd)
+ # The per-agent usage dir is mounted so finalize reads usage.json back.
+ assert "/h/gu/intake-1:/home/agent/.grok-usage" in cmd
+ assert "ROBOCO_AGENT_MODEL=grok-build" in cmd
+ assert "ROBOCO_GROK_USAGE_FILE=/home/agent/.grok-usage/usage.json" in cmd
+ assert cmd[-1] == GROK_PROMPTER_IMAGE
+ # No metered xAI key, no Anthropic mislabelling, no stale opencode contract.
+ assert not any(c.startswith("XAI_") for c in cmd)
+ assert not any(c.startswith("ANTHROPIC_") for c in cmd)
+ assert not any(c.startswith("ROBOCO_GROK_VARIANT") for c in cmd)
+ assert not any(c.startswith("ROBOCO_GROK_EDIT_PERMISSION") for c in cmd)
+ assert "/home/agent/.local/share/opencode" not in " ".join(cmd)
+
+
+def test_intake_grok_mounts_subscription_auth_when_present(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # The auth mount is .exists()-guarded; point the host dir at a tmp ~/.grok
+ # holding an auth.json so the mount is emitted.
+ grok_dir = tmp_path / ".grok"
+ grok_dir.mkdir()
+ (grok_dir / "auth.json").write_text("{}", encoding="utf-8")
+ monkeypatch.setattr(grok_provider, "GROK_AUTH_HOST_PATH", str(grok_dir))
+
+ cmd = AgentOrchestrator._build_intake_run_cmd(
+ _intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
+ )
+ assert f"{grok_dir / 'auth.json'}:/home/agent/.grok/auth.json:ro" in cmd
def test_intake_anthropic_keeps_anthropic_env() -> None:
@@ -98,34 +92,35 @@ def test_intake_anthropic_keeps_anthropic_env() -> None:
assert "ANTHROPIC_BASE_URL=https://api.anthropic.com" in cmd
assert "ANTHROPIC_AUTH_TOKEN=sk-ant" in cmd
assert not any(c.startswith("XAI_") for c in cmd)
+ assert not any(c.startswith("ROBOCO_GROK_USAGE_FILE") for c in cmd)
assert cmd[-1] == "roboco-agent-prompter"
-def test_secretary_grok_uses_openai_env_and_grok_image() -> None:
+def test_secretary_grok_uses_grok_cli_env_and_keeps_hmac() -> None:
spec = _SecretaryRunSpec(
container_name="roboco-agent-secretary-1",
image=GROK_SECRETARY_IMAGE,
- hosts={"claude": "/h/.claude", "prompt": "/h/p.md", "opencode": "/h/oc/sec-1"},
+ hosts={
+ "claude": "/h/.claude",
+ "prompt": "/h/p.md",
+ "grok_usage": "/h/gu/sec-1",
+ },
session_id="sess-2",
cwd="/app",
- cli_model="grok-build-0.1",
+ cli_model="grok-build",
api_url="http://roboco-orchestrator:8000",
agent_uuid="uuid-sec",
agent_token="hmac-secretary",
provider_base_url="https://api.x.ai/v1",
provider_auth_token="xai-key",
provider_type="grok",
- model="grok-build-0.1",
+ model="grok-build",
)
cmd = AgentOrchestrator._build_secretary_run_cmd(spec)
- assert "XAI_API_KEY=xai-key" in cmd
- assert "/h/oc/sec-1:/home/agent/.local/share/opencode" in cmd
+ assert "/h/gu/sec-1:/home/agent/.grok-usage" in cmd
+ assert "ROBOCO_AGENT_MODEL=grok-build" in cmd
# The HMAC identity the directive tools authenticate with survives.
assert "ROBOCO_AGENT_TOKEN=hmac-secretary" in cmd
assert cmd[-1] == GROK_SECRETARY_IMAGE
+ assert not any(c.startswith("XAI_") for c in cmd)
assert not any(c.startswith("ANTHROPIC_") for c in cmd)
- # The Secretary is read-only and reads only /app + the API, so edit/bash
- # are denied and it gets NO external-directory reads (unlike intake).
- assert "ROBOCO_GROK_EDIT_PERMISSION=deny" in cmd
- assert "ROBOCO_GROK_BASH_PERMISSION=deny" in cmd
- assert "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=deny" in cmd
diff --git a/tests/unit/runtime/test_stale_claim_reaper.py b/tests/unit/runtime/test_stale_claim_reaper.py
index a14a199f..72d21e77 100644
--- a/tests/unit/runtime/test_stale_claim_reaper.py
+++ b/tests/unit/runtime/test_stale_claim_reaper.py
@@ -153,7 +153,7 @@ async def test_reaper_kills_and_releases_wedged_grok_container(
) -> None:
"""A GROK container idle past the kill TTL is killed, evicted, and released.
- Unlike a Claude agent, a wedged opencode container is ACTIVE yet fires no
+ Unlike a Claude agent, a wedged grok container is ACTIVE yet fires no
verb, so the live-instance skip would shield it forever. Past the longer
grok-idle TTL the watchdog removes the container and drops it from
`_instances`, so the same reap pass then unclaims the task.