feat(grok): convert interactive intake/secretary to the grok CLI; delete opencode

Move the last Grok runtime off opencode onto xAI's official `grok` CLI, for full
parity with the Claude path. The intake/secretary chat now runs per-turn headless
`grok -p` invocations that resume one session id (proven live: context carries
across runs), with streaming-json deltas mapped to the existing panel StreamChunk
kinds — the IntakeDriver loop, message source, relay, and idle reaper are reused
unchanged; only the SessionFactory differs (GrokCliSession replaces the
opencode-serve session).

- GrokCliSession + a pure, unit-tested streaming-json -> StreamChunk assembler
  (thought coalesced to one block, text streamed live, end captures the session
  id for -r, fenced-draft fallback, clear errors incl. rate-limit).
- intake propose_draft and secretary read_company_state/read_task/submit_directive
  are now FastMCP servers (roboco-intake / roboco-secretary) wired into
  ~/.grok/config.toml, launched via `uv run --directory /app` to resolve the
  installed package. The secretary tools reuse the shared backend helpers.
- Orchestrator: interactive spawn mounts the subscription auth + per-agent usage
  dir (no metered xAI key, no permission env — grok flags carry per-role perms);
  usage/cost now read a captured usage.json (drop the opencode.db reader, the
  _opencode_db_path/_grok_usage_from_opencode methods, and the cost-cap's
  opencode read). hosts["opencode"] -> hosts["grok_usage"]; OPENCODE_DATA_DIR ->
  GROK_USAGE_DATA_DIR.
- Fix one-shot usage capture: `-s` does not pin the session id (grok generates
  its own), so the entrypoint now reads the real id back from the JSON run log
  and the reader uses it; usage is captured per-turn on the interactive path.
- Delete the opencode layer: opencode_config/opencode_usage/opencode_session, the
  docker/grok/*.js plugins, the old one-shot entrypoint, and their tests.
- Compose (all three files), .env.example, and stale comments updated to the
  grok-CLI runtime; add the SuperGrok auth mount + grok-usage dir.

Gate green: ruff, mypy (296 files), xenon, tests. NAS build/verify pending.
This commit is contained in:
Renn F
2026-06-19 04:42:25 +02:00
parent 499f6fc509
commit a88045aacf
40 changed files with 1307 additions and 2200 deletions
+10 -19
View File
@@ -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
+10 -20
View File
@@ -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
-136
View File
@@ -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 "<server>_<verb>" (or "<server>.<verb>");
// 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_<verb>" (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),
});
},
};
};
-70
View File
@@ -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.";
},
}),
},
});
-192
View File
@@ -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.",
);
}
}
},
};
};
-118
View File
@@ -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 || {},
}),
);
},
}),
},
});
-124
View File
@@ -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"
+14 -15
View File
@@ -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;" \