Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Dockerfile.agent
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ ARG CLAUDE_CODE_VERSION=latest
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
&& claude --version

# opencode — pinnable via build-arg, same pattern as claude-code:
# docker compose -f compose.agents.yml build --build-arg OPENCODE_VERSION=x.y.z
ARG OPENCODE_VERSION=latest
RUN npm install -g opencode-ai@${OPENCODE_VERSION} \
&& opencode --version

# Interactive by default; agents are started explicitly, e.g.
# docker compose -f compose.agents.yml run --rm agent claude
CMD ["bash"]
17 changes: 16 additions & 1 deletion PLAN-CONTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ git-clean checkout, not the working tree) is what actually closes it.
and settings hooks are argv the HOST runs on next start — a rw mount hands the
contained agent a container-escape channel. Default layout: container-private named
volumes for `/root/.gemini` and `/root/.claude`, seeded by an in-container login
(agy has a headless URL+code flow; claude via `claude setup-token` →
(agy: one-time npm run agent -- agy URL+code per volume, own refresh lineage, no race with the host; claude via `claude setup-token` →
`CLAUDE_CODE_OAUTH_TOKEN`). Open (first real session decides):
- [x] test seeding from host credential _files_ mounted `:ro` (config stays out)
- [x] verify agy's credential store in a keyring-less container actually falls back
Expand Down Expand Up @@ -229,6 +229,21 @@ today — config is code. The deployed instance still reads it from a clean
checkout; the deployment model is unchanged by extraction, which is precisely why
it isn't worth pre-empting.

**Endpoint sharpened (2026-07-17, with hyphe PLAN-PHORGE §3.6)**: the second
project arrived (hyphe ported phorge, smoke passed), and with it the insight that
the agent-dispatch half was never game tooling. Once the hyphe-owned
**multi-tenant** phorge deployment serves the phlame tenant end to end (criterion:
one agent dispatched into a phlame worktree, collected, merged), phlame sheds the
`agy`/`claude`/`opencode`/`models`/`fmt` verbs, the agent stack
(`Dockerfile.agent`, `compose.agents.yml`, `tools/agent/`) and its copy of the
phorge server code. What stays HERE is what belongs to the game repo: the verb
**manifest** (run-verb table over `compose.test.yml`, Dockerfiles, AGENTS.md — how
to verify THIS repo is repo knowledge, and CI needs it anyway) and the pure
`phlame-game` MCP. Side benefit for the public repo: private-ops agent infra
leaves the open-source surface; contributors keep the plain npm/docker paths,
which never depended on phorge. Until that criterion is met, phlame:4201 keeps
all its verbs — nothing gets deleted transitionally.

## Milestones

### C0 — Runner infra (this branch)
Expand Down
14 changes: 7 additions & 7 deletions compose.agents.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
# - Agent config/credentials live in container-private named volumes: host ~/.gemini /
# ~/.claude are host-EXECUTED configuration (MCP entries, settings hooks) — mounting
# them rw would hand the contained agent a write path onto the host. Login happens
# in-container (agy: headless URL+code flow; claude: CLAUDE_CODE_OAUTH_TOKEN below).
# in-container (agy: one-time npm run agent -- agy URL+code per volume, own refresh lineage, no race with the host; claude: CLAUDE_CODE_OAUTH_TOKEN below).
# Own project name: the test stack (compose.test.yml) lives under the default
# project `phlame` — sharing it would make compose treat each other's services as
# orphans (and a stray `down --remove-orphans` here would reap test containers).
Expand Down Expand Up @@ -53,10 +53,14 @@ services:
# From `claude setup-token` (run on a trusted machine), pasted into .env —
# authenticates claude without sharing host config into the container.
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-}
# Claude's official container escape hatch: it refuses
# claude's official container escape hatch: it refuses
# --dangerously-skip-permissions as root UNLESS this is set. Deliberate —
# yolo mode is the point in here, the container wall is the boundary.
IS_SANDBOX: 1
# opencode provider keys — pass through whatever your litellm/proxy serves.
# The opencode run verb uses -m provider/model; the key must match the provider.
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
extra_hosts:
# Docker Desktop ships host.docker.internal; this keeps native-Linux hosts
# working too (where Phorge would additionally need a non-loopback binding).
Expand All @@ -77,11 +81,7 @@ services:
# Container-private agent state (see trust model above).
- agent_gemini:/root/.gemini
- agent_claude:/root/.claude
# agy credential seed: host store mounted read-only, copied into the live
# volume by tools/agent/setup.sh on container start (agy has no headless
# token equivalent yet). NOTE the rotation hazard: if agy ever rotates
# these, host and container copies drift — watch the host login.
- ${USERPROFILE:-$HOME}/.gemini/antigravity-cli/implicit:/root/.gemini/antigravity-cli/implicit-host:ro

# claude needs NO file seeding: CLAUDE_CODE_OAUTH_TOKEN (env above) is the
# subscription login in token form (`claude setup-token`, one-time).
# Sharing ~/.claude/.credentials.json instead would race the host on
Expand Down
10 changes: 10 additions & 0 deletions opencode.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"phorge": {
"type": "local",
"command": ["npx", "tsx", "tools/phorge/server.ts"],
"enabled": true,
},
},
}
24 changes: 20 additions & 4 deletions tools/agent/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# picked up by recreating the container (docker compose up -d --force-recreate).
set -eu

mkdir -p ~/.gemini/config ~/.gemini/antigravity-cli/implicit
mkdir -p ~/.gemini/config

# Git identity + trust for agent commits (task worktrees under
# /phlame/.worktrees/<slug> are separate working dirs — the Dockerfile's
Expand All @@ -32,8 +32,6 @@ EOF
# volume and an already-migrated one agree.
cp ~/.gemini/mcp_config.json ~/.gemini/config/mcp_config.json

# Copy the ro-mounted host credential seed into agy's live store (rw).
cp -f ~/.gemini/antigravity-cli/implicit-host/*.pb ~/.gemini/antigravity-cli/implicit/ 2>/dev/null || true

# claude: authenticates via CLAUDE_CODE_OAUTH_TOKEN (env) — no files needed.
# The repo .mcp.json carries the HOST-side stdio phorge entry, which cannot work
Expand All @@ -58,4 +56,22 @@ if [ ! -f ~/.claude.json ]; then
echo '{"projects":{"/phlame":{"hasTrustDialogAccepted":true}}}' > ~/.claude.json
fi

echo "[agent-setup] agy + claude mcp configs generated, credentials seeded"
# opencode: the project-level opencode.jsonc carries a stdio phorge entry that
# cannot work behind the container wall (no Docker here). Overwrite it with the
# HTTP endpoint — the host file is ephemeral on the rw mount; the user's
# canonical copy is versioned and restorable.
cat > /phlame/opencode.jsonc <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"mcp": {
"phorge": {
"type": "remote",
"url": "${PHORGE_URL}",
"headers": { "Authorization": "Bearer ${PHORGE_TOKEN}" },
"enabled": true
}
}
}
EOF

echo "[agent-setup] agy + claude + opencode mcp configs generated"
26 changes: 26 additions & 0 deletions tools/phorge/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,32 @@ export function planClaude(
return args;
}

/**
* Headless opencode prompt inside the agent container — `opencode run` is the
* single-shot headless mode. --auto auto-approves permissions (the container
* wall is the boundary, same rationale as claude's --dangerously-skip-permissions).
* The opencode.jsonc with the phorge MCP is generated by tools/agent/setup.sh.
* The prompt is passed as positional args after `--` to avoid flag conflicts.
*/
export function planOpencode(
slot: number,
prompt: string,
model?: string,
workdir?: string,
): string[] {
const args = ['exec'];
if (workdir) {
args.push('-w', workdir);
}
args.push(getAgentContainer(slot), 'opencode', 'run', '--auto', '--dir', workdir ?? '/phlame');
if (model) {
args.push('--model', model);
}
const fullPrompt = workdir ? worktreePreamble(workdir) + prompt : prompt;
args.push(fullPrompt);
return args;
}

/** List agy's selectable models (read-only, no yolo flag needed). */
export function planAgyModels(): string[] {
return ['exec', getAgentContainer(1), 'agy', 'models'];
Expand Down
4 changes: 3 additions & 1 deletion tools/phorge/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
planAgentUp,
planAgy,
planClaude,
planOpencode,
planAgentRestart,
planWorktreeCheck,
planWorktreeAdd,
Expand All @@ -22,7 +23,7 @@ import { execDocker, type ExecLimits, type ExecResult } from './exec';

export type Exec = (argv: string[], limits?: ExecLimits) => Promise<ExecResult>;
export type Discover = () => Promise<SpecList>;
export type AgentCli = 'agy' | 'claude';
export type AgentCli = 'agy' | 'claude' | 'opencode';

export const MAX_RUNNER_CONCURRENCY = 4;
export const MAX_PLAYWRIGHT_CONCURRENCY = 1;
Expand All @@ -39,6 +40,7 @@ const AGENT_PLANS: Record<
> = {
agy: planAgy,
claude: planClaude,
opencode: planOpencode,
};

/**
Expand Down
30 changes: 30 additions & 0 deletions tools/phorge/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,36 @@ export function registerTools(server: McpServer): void {
},
);

server.registerTool(
'opencode',
{
description:
'Run a headless opencode prompt inside an agent container (compose.agents.yml) and return its answer. yolo mode (--auto), phorge wired via generated opencode.jsonc. Two agent slots shared with agy/claude; 6-minute timeout; returns the output tail, pass verbose for more.',
inputSchema: {
prompt: z.string().min(1).describe('the task/question for the containerized opencode run'),
verbose: z
.boolean()
.optional()
.describe(`full output up to ${VERBOSE_TAIL_CHARS} chars instead of the default tail`),
model: z
.string()
.optional()
.describe(
"the model to use in provider/model format (e.g. 'anthropic/claude-sonnet-4-20250514')",
),
worktree: WORKTREE_SLUG,
},
},
async ({ prompt, verbose, model, worktree }) => {
try {
const { result, note } = await execAgent('opencode', prompt, model, worktree);
return verdict('opencode', result, note, verbose ? VERBOSE_TAIL_CHARS : TAIL_CHARS);
} catch (error) {
return fail(error);
}
},
);

server.registerTool(
'models',
{
Expand Down
Loading