English · 中文
gofer bridges configurable CLI agents (codex / claude / omp / opencode / any command) and registered projects into one asynchronous job control plane: submit {project, agent, prompt or command, cwd}, and gofer runs it inside that project's real working directory (locally, on a remote worker, or on a peer), streams status, logs, exit code and results back, and lets you submit and observe through CLI / HTTP / MCP / Web console.
A gofer is "the one who runs errands": hand a task to an agent → run it in the target project → bring back logs and results. The
gofer↔gopherpun is intentional.
- Features · Architecture · Install / build · Quick start
- Core concepts · Submitting jobs · Parallel jobs: --worktree · Continuing a job: resume
- Remote execution and workers · Reconnect recovery · Tunnels
- Human in the loop · Logging and observability
- Configuration · CLI · MCP · Web console · HTTP API
- Deployment · Security · History
- One control plane, four entry points: CLI (
gofer job …), HTTP (/v1/*), MCP (stdio, 23gofer_*tools) and a Web console (board, job detail, live logs, runners, plans, sessions, new-job form) — all on the samejob.Service. - Many agents, one key for two modes:
type: cli-agentrenders an argv template (argsfor batch runs,interactive_argsfor pty sessions);type: execruns argv verbatim. Agents that are not installed are merely markedunavailable. - Per-project governance:
host_path/container_path, allowed agents and runners,allow_exec,allow_interactive, concurrency cap, timeout ceiling, default worktree. - Three execution places (runners):
local(in-process),peer-http(forward to another gofer),worker(remote executor over WebSocket, label-based scheduling). Remote logs, status and interactions are mirrored back transparently. - Reconnect recovery: when a worker link blips or the server restarts, in-flight jobs enter
recovering; the same worker process reconnecting within the window resumes log streaming and delivers the result — jobs no longer fail on the first hiccup. - Managed worktrees:
--worktreeruns each job in its own git worktree so parallel agents never step on each other. - Same-directory serialization and stall detection: two writable agent jobs never edit one working directory at once — the second parks in the non-terminal
waiting_dir(queued, cancellable,job.waiting_dir {holder_job}names who it waits for) until the first releases it; exec and read-only jobs are shared by default and--exclusive-dir/--shared-dir/server.dir_lock: falseoverride either way (agents.<k>.max_concurrentcaps one agent's parallelism the same way). A job that produces no output at all forserver.stall_timeout_sec(default 900s; off for exec, per-agent overridable,--stall-timeout/--no-stallper job) is killed asfailed: stalled: no output for Nsand classified transient, so the resume/failover chain takes over instead of waiting out the deadline. - Resume:
job resumelets codex/claude continue from where an interrupted job stopped, with its own session context. When a configured transient error matches, the server automatically resumes once (server.auto_resume_max; set it to0to disable). If that continuation also dies (or there is nothing to continue), the job is handed to the next agent instead — see Failover to another agent below. - Tunnels:
gofer tunnelforwards TCP/UDP ports through a worker under an allowlist (e.g. container → shop-floor PLC/HMI); all three ends log the sametunnel_idwith per-stage latencies. - Human in the loop: mid-run questions (
pending_interaction),plan+ todo progress boards, blockingask_humandecisions, and a terminal session relay that arms itself when you walk away and injects your web/phone reply into the very same session. - Failover to another agent: when codex dies of a provider error (
at capacity/stream disconnected/ a sandbox that never started) and cannot continue the job itself, the server hands the SAME work to the next candidate as an ordinary job —fallback_agents: [omp]on the agent oragent_fallbacks: {codex: [omp]}per project (one job can override withjob run --fallback omp/--no-fallback). The takeover keeps the worktree, plan/todo, verify step and caller, gets a prompt that says what happened and to finish only the remaining work, and the source row records the link (fell_back_to) plusjob.fell_backinstead of a terminal failure. Health is aggregated per agent (failure_classon every failed job):gofer agent statusshows who is degraded,gofer agent probe <key>submits a real one-line OK job to check NOW, and the web Agents page badges it (withserver.agent_fallback.pre_dispatch: truea degraded agent is even replaced before dispatch — off by default). - Task-book templates: the constraints you paste into every task book live in a file the SERVER renders —
<project>/.gofer/templates/<name>.mdor the global<config-dir>/templates/<name>.md(YAML frontmatter = job defaults + variable declarations, body = prompt with{{var}},{{include: sibling.md}},{{project}}/{{cwd}}/{{date}}/{{head}}). Submit withjob run -t <name> --var k=v …, inspect withgofer template ls|show, or pick one in the web form (variables + rendered preview included). A flag beats the template default beats the project default;request_jsonkeeps the RENDERED prompt beside the template/vars, so a rerun never re-renders. See Task-book templates below. - Checklist linkage:
job run --todo <todo-id>turns that plan itemdoingand writes the outcome back into its note when the job ends — status plus the commits it produced (<job-id> ✓ 3 commits: …), or the failure reason. A todo can also dispatch itself:ready+ an assignee (plan set-todo <id> --assign omp --status ready) starts the job for it, so a plan of assigned items runs itself one step at a time — and since PLAN-03 the items can be chained:plan add-todo … --after prevmakes an item wait for the one before it,plan run <plan>queues the ready roots, and each finished item queues the next, so a whole multi-step task (ending in an--assign exec --cmd '<build/test>'verification step) runs itself. A failing chain item parks the plan (status=blocked+ aplan.blockednotification) until a human re-runs or skips it. The commits themselves are captured on every job (base_sha→git log base..HEAD) and shown byjob show/ the job detail page. - Supervision-aware relay: a session whose authenticated caller currently has jobs in flight no longer arms the automatic session relay ("you walked away" while you are actually watching jobs).
agent_sessions.caller_idmakes that decidable; onlyrelay_mode: autois affected. - Verification, not vibes: an agent's report is not acceptance — add
job run --verify 'go test ./...'(or a projectverify:default) and the check runs on the machine that did the work, right after the agent, in the job's own cwd/env. A non-zero exit fails the job (or parks it inneeds_review), the output lands in the job's stderr log with its banners, and a worker-side step reports its result back through the same outcome channel. Remote jobs also mirror their approval-gate and verify events up to the hub, so notifications and audit see them. - Scheduling and orchestration:
schedulefor cron jobs (each one can also expose an external webhook trigger —schedule add … --webhookmints its owntrigger_token,POST /v1/schedules/{id}/trigger?token=…runs it with no gofer bearer, 10s rate-limited),workflowfor dependent multi-step chains (fan-out / join / retries). - Usage and cost: agents that report their own tokens land on the job (
jobs.usage_json:in/out/cache/total+cost_usd+ which parser produced it) — read from omp/claude ndjson, codexexecstderr and acpusage_update.job showprints oneusage:line, the job detail page has a block, and/v1/stats+ the Home card aggregate 24h/7d per agent. Best-effort by design: an agent that reports nothing shows-, never0. - Worker events reach the hub: approval-gate and verify events of jobs running on a worker (
job.permission_requested|answered|timed_out,job.verify_started|finished) are mirrored into the hub's job events (deduplicated), so notifications and audits see remote jobs too. - Observable and auditable: JSONL file logs (rotation, redaction),
/v1/runnershealth roster, SSE live streams,caller_id/worker_idpersisted, retention pruning; SQLite (pure Go) for metadata. - Windows friendly:
scripts/start.ps1runs the server as a logon scheduled task inside the desktop session (crash-restarting watchdog, one-shotupgrade), ConPTY-backed interactive sessions.
entry points control plane (serve) execution (runner) agent
┌───────────────┐ ┌────────────────────────┐ ┌─ local (in-process) ─┐
│ CLI gofer job │──HTTP────▶│ /v1/* job.Service │─────▶│ peer-http (another gofer) │──▶ cli-agent
│ HTTP /v1/* │ │ registry: project/agent │ │ worker (remote over WS) │ (codex/claude/…)
│ MCP stdio │ │ runners + ws hub │ └────────┬───────────┘ or exec(argv)
│ Web console │ │ jobstore(SQLite)+logs │◀──── mirrored logs/status/interactions ─┘
└───────────────┘ └────────────────────────┘
Authorization: Bearer <token> (except /health) results: <host_path>/tmp/gofer/<job_id>/ + DBGo 1.25+ (plus Node + pnpm if you want the Web console).
make build # current platform → dist/gofer (upx-compressed by default; without upx use go build below)
go build -o dist/gofer ./cmd/gofer
make web build # build the console and embed it into the binary
make build-all # linux/darwin/windows × amd64/arm64# 1. Register a project (host-path must be an existing absolute directory)
gofer project add workspace \
--host-path /work/projects/workspace --container-path /workspace \
--default-agent codex --allow-agent codex --allow-agent claude --allow-agent exec \
--allow-runner local --allow-exec
# 2. Start the server (token from env)
export GOFER_TOKEN=dev-token
gofer serve --addr 0.0.0.0:8765
# 3. An exec job (argv after --); --sync makes the server wait for the terminal state
gofer job run -p workspace -a exec --sync -- go version
# → status=done exit_code=0
# 4. A cli-agent job; read its logs
gofer job run -p workspace -a codex --prompt "Summarise the failing tests in this directory" --wait
gofer job logs <id> --stream stdout
# 5. Open http://<addr>/ in a browser and paste the tokenA pure client (for example inside a container that only submits work) needs no yaml at all:
gofer init client # writes <config-dir>/.env: GOFER_SERVER_ADDR / GOFER_SERVER_TOKEN / GOFER_RUN_MODE=client
gofer job list # fill in the address and token and you are done- project: a real directory work can run in. Fields:
host_path/container_path/default_agent/allowed_agents/agent_fallbacks(per-project fallback candidates, keyed by the failing agent) /allowed_runners/allow_exec/allow_interactive(the project-level switch for pty/interactive jobs, off by default, and the only interactive gate on the project side) /max_concurrent_jobs/max_timeout_sec/worktree_default/verify+verify_timeout_sec(the project's default verification step and its own deadline). - agent: how to run. A
cli-agentrenderscommand+args(placeholders{{prompt}}{{cwd}}{{job_id}}{{result_dir}}, substituted per argv element, never through a shell); addinteractive_argsand the same key serves both batch and pty runs ([]= bare TUI launch; must not contain{{prompt}}).execruns the request'scmdargv verbatim (needs the project'sallow_exec). - runner: where to run.
local(child process of this server) /peer-http(forward to another gofer) /worker(remote executor connected over WebSocket). - job lifecycle:
queued → running → done | failed | cancelled | timeout; a job queued behind the same-directory lock isqueued → waiting_dir → running; a mid-run question isrunning → pending_interaction → running; a worker link loss isrunning → recovering → running | failed.
One JobRequest, four entry points, two timings:
# CLI — --prompt for cli-agents, -- argv for exec
gofer job run -p workspace -a codex --prompt "Review the changes and list the risks"
gofer job run -p workspace -a exec --sync -- mvn -q test # --sync: server waits for the terminal state
gofer job run -f task.md # md+yaml: frontmatter = parameters, body = prompt
gofer job run -p workspace -t impl-batch --var tasks="add a foo subcommand" \
--prompt "extra: do not touch web/" # -t: server-rendered task book
gofer job run -p workspace -a codex --read-only --prompt "Review only: list the risks, change nothing"
# --read-only: the agent cannot write (cli sandbox
# args / acp-agent session/set_mode; inherited by resume)
gofer job run -p workspace -a codex --review --prompt "Refactor the parser" # a human must accept the result
gofer job accept <job-id> [--note "looks good"] # needs_review -> done
gofer job reject <job-id> --note "why it is refused" [--resume] # -> rejected; --resume continues with the note<!-- task.md -->
---
project_key: workspace
agent: codex
runner: worker
worker_labels: [gpu] # or worker_id: w-01
worktree: true
---
Generate batch scripts under scripts/ that read the task list from config.yaml … (the body is the prompt)- Templates:
job run -t <name> [--var k=v …]renders a server-side task book into the prompt (--promptis appended);gofer template ls|showlists and previews them (the preview is the server's own render, includes expanded). - HTTP:
POST /v1/jobs(JSON ortext/markdown)."sync": trueor?wait=1waits (terminal state →200with the full result; past the server cap →202+X-Gofer-Async: 1+ id). - MCP:
gofer_run_joband friends (see MCP). - Web: the "+ New job" form.
- Sync vs async: async by default (returns the
idimmediately; follow withjob watch <id>);--syncwaits server-side (30s cap by default,--wait-timeoutadjusts it, then falls back to async). - Timeout ceiling: a
--timeoutaboveserver.max_job_timeout_sec(default 3600) or the project'smax_timeout_secis clamped, never silently: the CLI printswarning: --timeout <requested>s exceeds the project ceiling (<max>s)…and the response carriesrequested_timeout_sec/timeout_clamped.
Every batch of work repeats the same preamble ("no push, LF only, one commit per task, paste the raw
go test lines"). Put it in a template once and submit with variables:
gofer template ls [-p workspace] # project .gofer/templates first, then <config-dir>/templates
gofer template show impl-batch --var tasks="add a foo subcommand" # source path + variables + rendered preview
gofer job run -p workspace -t impl-batch --var tasks="add a foo subcommand" --var base=main<!-- <project>/.gofer/templates/impl-batch.md -->
---
desc: one implementation batch
agent: omp
timeout_sec: 3600
verify: [go, test, ./...]
vars:
tasks: {required: true, desc: the batch body}
base: {default: main}
---
# Batch
{{include: common.md}} <!-- one level, same directory only -->
## Tasks
{{tasks}}- Precedence is explicit flag > template default > project default: the frontmatter may preset
agent,runner,timeout_sec,tags,verify,verify_timeout_sec,review,read_only,worktree,fallback_agents— and only fills what the request left unset. A missing required variable is a 400 naming it. - Where they live:
<project host_path>/.gofer/templates/<name>.md(wins on a name clash) or the server's<config-dir>/templates/<name>.md. Templates are read by the server, so a remote console or CLI still sees the same list; a worker-only project uses the global directory. - Audit:
request_jsonstores the RENDERED prompt next totemplate/vars, and replays (rerun / rebuild / failover) replay that prompt instead of rendering the task book a second time. - Ready-made examples:
docs/examples/templates/(cp docs/examples/templates/*.md ~/.config/gofer/templates/).
Several jobs editing the same checkout collide (stale .git/index.lock, overwritten changes). --worktree gives each job its own git worktree:
gofer job run -p workspace -a codex --worktree [--worktree-base v1.2.0] --prompt "Fix the 3 issues, one commit each"
gofer job worktree ls [-p workspace] # branch / commits ahead / dirty / merged
gofer job worktree rm <job-id> [--force] [--delete-branch] # refuses a dirty tree without --force; keeps the branch by default- Location
<repo top>/tmp/gofer/wt/<job-id>, branchgofer/<job-id>(base defaults to the current HEAD);--cwd submaps to<worktree>/sub; nested repositories use the nearest git top-level of the cwd; the worktree is created on the executing machine. - Environment
GOFER_WORKTREE/GOFER_WORKTREE_BRANCH/GOFER_WORKTREE_BASE; the result recordsworktree_path/worktree_branch/worktree_base_sha/worktree_head_sha/commits_ahead;changes.diffhas acommitted (base..HEAD)and anuncommittedsection. - Kept by default when the job ends (the commits on the branch are the deliverable); retention only removes worktrees that are clean and merged.
worktree_default: trueon a project turns it on for every job. Seedocs/runbook/parallel-jobs-with-worktree.md.
When a provider capacity error, a network blip or a timeout kills a job halfway, do not resubmit the whole task (a new session re-reads all the context):
gofer job resume <source job-id> --prompt "The previous run was interrupted by <reason>. Check git status/log to see how far you got, finish only the remaining items, do not redo committed work."Requirements: the source job is terminal, it captured a session_id (visible in job show; codex/omp via output capture — the ndjson session row or the TUI exit banner — and claude via --session-id injection), the agent has a resume template (built in for claude/codex/omp; every other cli-agent gets the generic fallback — a --resume-style capture regex and --resume {{session_id}} argv — so a newly declared agent is resumable without any session_* config; override with session_capture / session_resume when its syntax differs), same runner. rerun, by contrast, resubmits the same request as a fresh session. See docs/runbook/2026-09-22-cli-agent-onboarding-runbook.md.
"Agent finished" is not "the work is accepted". With job run --review — or a project's require_review: true, or a workflow step's review: true|false override — a job whose agent finishes normally stops in the non-terminal needs_review state instead of done, and stays there until a person rules:
gofer job accept <job-id> [--note "looks good"] # -> done (job.reviewed{accepted} + job.terminal{done})
gofer job reject <job-id> --note "tests are red" # -> rejected (terminal; the note is required)
gofer job reject <job-id> --note "tests are red" --resume # also start a continuation with the note as its prompt
gofer job list --status needs_review # what is waiting on a human
gofer job review <job-id> [--tail 60] [--diff] # the acceptance material on ONE screen: status / review / verify / commits / usage / diff --stat + the report tail (--diff appends the full patch)- A failure (
failed/cancelled/timeout) never enters review; only a normal completion does.rejectedis terminal, is aggregated as a failure by a workflow step, and is never retried or auto-continued — only a person's--resumecontinues the work. - Only a person can accept:
POST /v1/jobs/{id}/accept|rejectrefuses a worker token with 403 (and requirescan_answerwhengovernance.require_answer_capabilityis on); MCP exposesgofer_reject_joband deliberately no accept tool. Accepting happens on the web job page's review card, in the CLI, or over HTTP. job cancelrefuses aneeds_reviewjob (409) — there is nothing left to cancel, usereject.job resumelikewise demands the review be settled first.- Audit fields
require_review/reviewed_by/reviewed_at/review_noteare persisted and shown byjob showand the web page.job.needs_reviewis a default IM notification event (likejob.terminal);job.reviewedcan be subscribed explicitly.
Logs, status and mid-run interactions of remote jobs are mirrored back to the local job; every read path stays the same.
# A: peer-http — forward to another gofer
runners:
docker-peer: { type: peer-http, base_url: http://127.0.0.1:8766, token_env: PEER_TOKEN }
# B: ws-worker — a remote executor dials into this hub
server:
workers: # registered workers (worker_id ↔ token binding + scheduling labels)
w-gpu: { token_env: WTOK_GPU, labels: [gpu, linux] }
runners:
w-gpu: { type: worker, worker_id: w-gpu } # named worker runner (visible in the roster, explicitly routable)
worker: { type: worker } # generic worker runner (label-based dispatch)The worker side connects with its own config and executes locally:
gofer init worker -o worker.yaml # template
gofer -c worker.yaml config validate worker # checks token / host_path / roots
gofer worker --worker-config worker.yaml # startworker_id: w-gpu
server_link: { urls: [ws://hub:8765/v1/workers/connect], token_env: WTOK_GPU }
labels: [gpu, linux]
roots: # POLICY mode: projects are pushed by the server, only path prefixes are mapped here
- { from: D:/work, to: /srv/work }
guards: { allow_exec: true, allow_interactive: true } # local tightening only- Routing: explicit
{"runner":"w-gpu"}/--worker-id, or{"runner":"worker","worker_labels":["gpu"]}picks among connected workers whose labels contain all requested ones, byin_flight↑ → heartbeat freshness↑; no candidate →503. The chosenworker_idis recorded on the result. - Three places must agree: the
server.workers.<id>key,runners.<name>.worker_idand the worker's ownworker_idare the same string; the worker's token equalsserver.workers.<id>'s. - LEGACY vs POLICY: a worker.yaml with
rootsis POLICY (the project set comes from the server; adding a project touches nothing on the worker); one with onlyprojectsis LEGACY. Self-check withgofer config validate worker/gofer project list. - Pre-flight self-check:
gofer worker doctorprints onePASS|WARN|FAILtable for the config, hub URLs (host resolution + TCP reachability), token, roots, installed agents, and a real register handshake against the hub (--jsonfor scripts, exit 1 on any FAIL); a container worker (Docker on the same host) is covered step by step indocs/runbook/container-worker.md. - Workers reconnect to multiple hub URLs with full-jitter backoff;
POST /v1/workers/{id}/reloadmakes a worker re-read its config.
When a worker link blips (WSL / Docker / VPN), its in-flight jobs no longer fail immediately: they enter recovering (yellow badge on the board, job list --status recovering) and wait for the same worker process to reconnect within server.job_recover_window_sec (default 120; 0 disables recovery, i.e. the old behaviour):
- On reconnect the worker reports its in-flight list and sent log offsets; the server answers with what it has persisted, so logs are neither lost nor duplicated; results produced while offline are delivered afterwards; a
job cancelissued during the outage is delivered on reconnect. - Window expired, or a new process reconnecting (
instance_idchanged) →failedwithworker lost …. - A server restart is covered too: the new server re-arms the window for the non-terminal worker jobs it inherited, and a reconnecting worker gets them adopted (back to
running, logs appended to the same files). - Notifications only fire on terminal states: recovering→running is silent, recovering→failed is delivered.
Forward local ports through a worker to targets on the worker's network (e.g. a container or office PC → a shop-floor PLC / HMI). Targets are restricted by the worker's tunnels.allow list; the server only relays:
gofer tunnel forward -w w-plc 1502:192.168.1.10:502 udp/21845:192.168.1.20:21845 # TCP + UDP
gofer tunnel save hmi -w w-plc udp/21845:192.168.1.20:21845 && gofer tunnel forward --name hmi
gofer tunnel check -w w-plc udp/192.168.1.20:21845 # proves the worker can open the socket, not that the device answers
gofer tunnel lsForwarder, server and worker log the same tunnel_id, with dial_ms, first_byte_ms, bytes_up|down, packets_up|down (UDP) and close_reason; GOFER_TUNNEL_TRACE=1 logs every datagram. How to tell a slow relay from a slow device or a chatty protocol is in docs/runbook/tcp-tunnel.md.
Move a single file between this machine and a worker (or the server's own machine) — no more base64 in a job log:
gofer tool cp ./firmware.bin w-plc:shop-floor/tmp/in/firmware.bin # push to a worker
gofer tool cp w-plc:shop-floor/tmp/out/report.csv ./report.csv # pull from a worker
gofer tool cp ./x.tar server:build/tmp/x.tar # the server host (`local` is the same)
gofer tool xfer ls [--state staged] | show <id> | rm <id> # staging areaThe remote side is <runner>:<project>/<relative path>, resolved on the executing machine inside that project's root — the same boundary a job's --cwd obeys; an existing destination needs --force. The payload rides HTTP (staged on the server, sha256 verified end to end, 256MB per file by default via server.xfer), while the WebSocket carries only the instruction, so a transfer that cannot run fails immediately with its reason (exists, worker offline, path escapes project, too large) instead of hanging. v1 moves single files and does not resume: tar / Compress-Archive a directory first. A job can also carry files with it: gofer job run --upload <local file>:<dest> stages the file onto the executing machine before the agent starts, and --collect '<glob>' uploads what the job left in its cwd into that job's artifacts (collected/<path>), so the web job page and the artifact download serve it directly.
-
Mid-run interactions: an agent asks via
POST /v1/jobs/{id}/interactions→ the job becomespending_interaction→ a human answers viaPOST …/answer→ the job continues. MCP:gofer_get_interactions/gofer_answer_interaction; web and IM notifications (DingTalk / Feishu webhooks underserver.notification). -
Approval gate (acp-agent): a project's
approvalblock decides what an ACP agent may do unattended —mode: off(default) auto-approves as before,askauto-approvesauto_allow_kinds(read/search/think/fetch) and asks a human for the rest,strictasks for everything. A pending request becomes an interaction of typepermission(the gated tool call + the agent's own ACP options + a countdown fromtimeout_sec), answered in the web job page,gofer job answer <id> <interaction-id> <optionId>, or MCPgofer_answer_interaction; an unanswered one is resolved byon_timeout(rejectby default, orallow). An agent can only TIGHTEN the project policy viaagents.<key>.acp.permission_policy; IM gets a notification (job.permission_requested, web link) but never answers. Each decision is audited in the job events (job.permission_requested|answered|timed_out) and<result_dir>/artifacts/acp.jsonl. -
Plan boards:
gofer plan create/add-todo/set-todo, jobs attached with--plan <id>; the web Plan page (phone-friendly) is the live progress view. A todo can also carry its own dispatch request (--assign omp --project <key> --template <book> --var k=v --verify '<cmd>' --review --runner <key> --cwd <dir> --timeout <sec>), andready+ an assignee dispatches it on the spot — one job per step, outcome written back automatically (plan dispatch <todo-id>is the explicit fallback,gofer_dispatch_todoover MCP) — and the plan header rolls up the tokens/$its jobs reported. Chained items (PLAN-03):--after <ids|prev>declares dependencies,plan run|pause|resume <plan>starts/holds/releases the chain, an item whose predecessors are done is queued automatically (--auto/--no-autoper item), an--assign exec --cmd '<argv>'item runs a command instead of an agent (the chain's build/test step), and a failed item parks the plan (blocked_todo+plan.blocked, in the notification default set) until someone sets itready/skippedor resumes. Decisions: the MCP toolgofer_ask_humanblocks until a human answers on the web (with a timeout fallback). -
Wakeups (JOB-09): register an event subscription or a timer on a job and let the job finish — when the condition arrives gofer starts ONE continuation of it (
job resumewhen the source has a session, else a re-run of the original request with the instruction appended), so "wait for the verify result / wait for a reply / look again in an hour" needs no resident process.gofer job wakeup create <job> --kind at --after 10m|every --every 1h|cron --cron '0 9 * * 1-5' [--tz …]|event --event job.terminal --job-id <other> [--status done,failed] (-m "…"|-f instr.md);--mode once(default for at/event) orcontinuous(default for every/cron). One wakeup has ONE outstanding continuation — further triggers only bumpcoalesced_count; timers never replay a missed tick; the default TTL is 7 days (wakeup.ttl_sec). From inside a job an agent registers on itself with$GOFER_JOB_ID; MCPgofer_wakeup_create|list|disable; the web job page has a 唤醒 block (list / switch / create / trigger history from thejob.wakeup_*events). -
Terminal session relay: after
gofer init hooks, a Claude Code / Codex session that stops can post its last message to the web "Sessions" page and wait; the reply is injected into the same session (gofer session relay auto|on|off,gofer session say; a session is owned by the caller that registered it — and while THAT caller still has jobs running,autodeliberately does not arm, so the job's completion notice is not stuck behind your own blocked Stop,session.auto_relay_skip_when_supervising). The switch is three-state:onwaits on every stop,offnever does,auto(the default) leaves it to the server — it arms once you have been away from the keyboard forsession.auto_relay_idle_sec(default 300,0disables) and releases the moment you touch it. Where the keyboard cannot be probed at all (a container hook, no X11 →xprintidle), thesession.auto_relay_turn_secfallback (default 900,0disables) arms on the time since your last input in that session instead, and your next input or Esc releases it. For a session that is merely idle (no turn waiting), the Sessions drawer's composer becomes "send to terminal" (gofer session say --deliver): the server types your text into that session's tmux pane via an internal exec job, so you can pick a parked session back up from the web — it needs the session running in tmux and a registered runner (a container session needs a gofer worker inside the container withGOFER_HOOK_RUNNERpointing at it). Where there is no usable tmux pane (a Windows Terminal session, a pane that is gone), the drawer offers a takeover: with an explicit opt-in (allow_takeover, the web's confirmation /session say --deliver --takeover) the server starts a NEW interactive pty job that continues the same CLI session (claude --resume <sid>et al., argv from the agent's interactive resume template) in the session's own project directory and types your text into it as the first input once the resumed TUI has settled (session.takeover_input_delay_ms, default 1500ms; 10s cap). The session is then reportedhanded_offand its original terminal stops relaying — its next Stop hook prints a one-line notice on stderr and lets the agent stop, because two processes writing one CLI session diverge.POST /v1/sessions/{sid}/release-takeover(the drawer's "release takeover") cancels that job and hands the session back. Takeover needsallow_interactiveon the project and an agent with an interactive resume template; the exec carrier is authorised as the source agent, so it does not needallow_exec. A takeover job that ENDS auto-releases the session back to idle (gofer session release-takeover <id>is the manual form, and an injection that never reached the pane —inject_failed:runner_error— now falls back to a takeover too); andgofer job review <id> [--diff]prints a job's whole acceptance material on one screen. -
Interactive pty jobs leave a readable record and a resumable session: a pty job's output never enters
stdout.log, so the relay also writes a de-ANSI'd transcript to<result_dir>/pty.txt(job logs/ the web log page fall back to it, tail-capped bypty.transcript_max_bytes, default 4MB). The session id is captured from the de-ANSI'd tail (head+tail windows, re-scanned when the relay closes, plus a terminal scan ofpty.txt), sojob resumecan continue an interactive session — including from the exit banner a text-mode TUI prints (omp --resume <uuid>); agents withsession_inject(claude--session-id) get it injected in TUI argv too, and the transcript keeps the words such a TUI drew with cursor motion apart (aESC[nCcolumn pad becomes spaces).
- File logs: server
<config-dir>/run/serve.log, workerrun/worker-<id>.log,tunnel forwardrun/tunnels/forward-<time>-<pid>.log(--log-file/--log-dir;--quietsilences only the terminal). JSON Lines, rotated bylog.max_size_mb/max_age_days/max_backups, withtoken/authorization/password/secretkeys redacted. An explicit path that cannot be opened fails startup; a default path only warns.-ddaemon mode (serve/worker) additionally keeps arun/*.out.logsidecar for panics and other non-slog output; a foreground process writes the file log only. - Events: each line carries
event(server.*/worker.*/tunnel.*/job.*),operation_id(one process run), andjob_id/worker_id/tunnel_idwhere relevant;GOFER_LOG_LEVEL=debug|info|warn|errorapplies to stderr and file alike. - Agent output: a structured agent (
omp --mode json,claude --output-format stream-json) writes one JSON event per line; withoutput_format: ndjsongofer PROJECTS that stream at capture time —stdout.logholds the agent's text only (omp defaults tondjson_stdout: assistant_text— every non-empty assistant message, narration plus the final report, blank-line separated, because a harness-injected trailing turn otherwise hides the report behind the last one-liner; claude defaults tofinal_text, theresulttext) andstderr.logthe compact events, one bounded line each (over-long fields marked…(truncated)), with the per-token noise dropped — 10-30x smaller, codex-shaped, and the web job detail renders the events as a timeline (ndjson_keepgates what reaches the projector,ndjson_events_to/ndjson_stdout/ndjson_stdout_path/ndjson_fieldstune it,ndjson_rawalso keeps the unfilteredstdout.raw.log; the counts land inndjson_kept/ndjson_dropped/ndjson_truncated). Anacp-agent(type: acp-agent) gets the same two-stream shape from its own runner:stdout.logholds the agent's text, one block per message (a tool call ends a block and the next block starts after a blank line),stderr.logthe compact{"type":…}events —tool_callper status change,thoughtcoalesced into one line (acp.log_thoughts: falsedrops it entirely),permission,plan,stop— while the job timeline keeps lifecycle only (the approval gate'sjob.permission_*plus ONEjob.acp_summaryper turn) and the full structured stream stays inartifacts/acp.jsonl. - API:
GET /v1/runnershealth roster;GET /v1/jobsfilters,/v1/jobs/{id}/streamSSE (logs + status + interactions),/logs/{stdout,stderr}(last 256KB),/diff,/artifacts,/events;GET /v1/metrics. - Audit:
caller_id(who submitted, derived from the token and overwritten server-side) andworker_id/worker_instance_id(where it ran) are persisted with the job;storage.retentionprunes old / excess terminal jobs. - Usage and cost: what the agent reported about its own run — tokens in/out/cached and, where the agent prices it, the cost — is captured from four sources (
output_format: ndjsonomp/claude streams, codex'stokens usedstderr tail, an acp agent'susage_updateevents), persisted asjobs.usage_jsonand shown byjob show(usage: in 12.3k / out 3.8k / cache 289k / total 305k / $0.0032 (ndjson:omp)), the web job page and the Home dashboard's Agent 用量 card (24h/7d per agent, fromGET /v1/stats→usage);gofer agent statusadds 24h tokens/$ per agent. Capture is best-effort: an agent that reports nothing produces no usage (never a zero tally), thesourcefield says where the numbers came from, and a remote job's usage is captured on the executing machine and returned with its outcome.
--config <path>→ 2.GOFER_CONFIG→ 3../.gofer.local.yaml→./.gofer.yaml→ 4.<config-dir>/config.yaml(default~/.config/gofer, override withGOFER_CONFIG_DIR).
GOFER_RUN_MODE |
local config | purpose |
|---|---|---|
server (default) |
the chain above | gofer serve; local jobs |
worker |
<config-dir>/worker.yaml |
connect to a hub and execute dispatched jobs |
client |
none (only <config-dir>/.env) |
pure client: project list / agent list read the server; serve / worker / project add etc. are refused with a clear message |
.env auto-loading: <config-dir>/.env (global) then ./.env (current directory, overrides); exported OS env always wins; never commit real tokens.
Full example: config/gofer.example.yaml.
server:
addr: 0.0.0.0:8765 # reachable from containers; security = mandatory token + network admission
token_env: GOFER_TOKEN # token source: token_env > inline token > --token
allow_empty_token: false
# max_job_timeout_sec: 3600 # --timeout ceiling; a project's max_timeout_sec overrides it
# job_recover_window_sec: 120 # reconnect recovery window; 0 = off
# workers: { w-gpu: { token_env: WTOK_GPU, labels: [gpu] } }
# callers: [ { id: docker, token_env: DOCKER_CALLER_TOKEN } ]
session: # terminal session relay: auto-arm thresholds (0 = that rule off)
# auto_relay_idle_sec: 300 # keyboard idle >= this -> a stopping session waits on the web
# auto_relay_turn_sec: 900 # no keyboard probe (container)? use the time since your last input
# auto_relay_skip_when_supervising: true # a session whose caller still has live jobs never auto-arms
# supervising_window_sec: 7200 # how far back that check looks for the caller's jobs
# inject_commands: [claude, codex, omp, node, gemini, opencode] # panes a web reply may be typed into
# takeover_input_delay_ms: 1500 # no tmux? wait this long after the resumed TUI's first output before typing
log:
max_size_mb: 50
max_age_days: 14
max_backups: 10
storage:
default_exchange_subdir: tmp
default_result_subdir: gofer
# root: /var/lib/gofer
# retention: { max_age_days: 30, max_count: 5000, prune_interval_minutes: 60 }
projects:
my-project:
host_path: /work/projects/my-project
container_path: /work/my-project
default_agent: codex
allowed_agents: [codex, claude, exec]
allowed_runners: [local, w-gpu]
allow_exec: true
allow_interactive: true
max_concurrent_jobs: 4
# max_timeout_sec: 7200
# worktree_default: true
agents: # placeholders: {{prompt}} {{cwd}} {{job_id}} {{result_dir}}
codex: { type: cli-agent, command: codex, args: [exec, "{{prompt}}"], interactive_args: [], detect: { command: codex, args: [--version] } }
claude: { type: cli-agent, command: claude, args: ["-p", "{{prompt}}"], interactive_args: [], detect: { command: claude, args: [--version] } }
exec: { type: exec }
runners:
local: { type: local }- The four agent shapes:
argsonly = batch only;args+interactive_args= dual mode; legacyinteractive: true(args are the pty argv) = interactive only;interactive: truewith{{prompt}}inargs= configuration error, rejected at load time. - Result directory:
<host_path>/tmp/gofer/<job_id>/by default (<root>/<project_key>/<job_id>/withstorage.root), holdingstdout.log/stderr.log/changes.diff/ artifacts; status and metadata live in SQLite.
The global -c/--config is an app-level flag and goes before the subcommand: gofer -c <path> <command> ….
gofer init [server|worker|client] [-o path] # config templates; init hooks installs the session-relay hooks; init skill installs the gofer-usage skill
gofer serve --addr 0.0.0.0:8765 [--no-web] [-d] # -d = daemon (detached; linux + windows)
gofer worker --worker-config worker.yaml [-d]
gofer config info | show <project> | validate [server|worker] | edit
gofer project list [--remote] | show <k> | add <k> … | remove <k> | validate <k>
gofer agent list [--local] | detect | show <k>
gofer job run … | list … | show <id> | watch <id> | logs <id> --stream … | cancel <id> | rerun <id> | resume <id> --prompt … | worktree ls|rm
gofer template ls [-p <project>] | show <name> [-p <project>] [--var k=v …]
gofer plan create | list | show <id> | add-todo | set-todo | dispatch <todo> | run | pause | resume <plan> | set-status | attach | ask | decisions | answer
gofer workflow run <file.yaml> [-w] | list | show <id> | events <id> | cancel <id> | export <id>
gofer schedule add … | list | show | enable | disable | run <id> | rotate-token <id> | rm <id>
gofer session ls | show <id> | relay auto|on|off | say <id> "…" | rm <id>
gofer tunnel forward | check | ls | save | saved | forget
gofer tool cp <src> <dst> [--force] [--timeout 600] | xfer ls | show <id> | rm <id>
gofer mcp [--standalone] # stdio MCP serverKey job run flags: -p/--project, -a/--agent, --runner (default server; local is the canonical key and server the alias — BOTH are accepted on every surface, the CLI, the HTTP API, a -f task file and a task-book template, and allowed_runners may list either; give the runner name for workers/peers), --cwd (relative to the project root), --prompt / -- argv / -f task.md / -t <template> [--var k=v …] (a server-rendered task book; --prompt is appended to it), --sync + --wait-timeout, --wait, --worker-id / --worker-labels, --interactive + --cols/--rows (needs the project's allow_interactive and an agent with interactive_args), --read-only (the agent cannot write: cli-agent read_only_args — built-in codex -s read-only / claude --permission-mode plan — or acp-agent acp.modes.read_only → session/set_mode; exec agents and agents without a read-only mode are refused), --worktree + --worktree-base, --review (a normal completion parks in needs_review until a human accepts or rejects it), --plan, --tags, --timeout, --title, -s/--server, --token.
Passing values across workflow steps:
${steps.N.result_dir}is an absolute path on the executing machine and is only readable within the same filesystem; across workers/peers use${steps.N.result}(inline result.json ≤ 32KB) /${steps.N.stdout}or a shared drive.
gofer mcp exposes the same control plane as a stdio MCP server (it talks to the server at GOFER_SERVER_ADDR by default; --standalone executes in-process). stdout is the protocol channel; no logs are written there.
{ "mcpServers": { "gofer": { "command": "/abs/path/to/gofer", "args": ["mcp"], "env": { "GOFER_CONFIG_DIR": "/abs/config/dir" } } } }Tools (snake_case, aligned with HTTP): gofer_list_projects gofer_list_agents gofer_run_job gofer_get_job gofer_tail_log gofer_get_result gofer_get_artifacts gofer_cancel_job gofer_attach_job gofer_get_interactions gofer_list_pending_interactions gofer_answer_interaction gofer_punt_interaction gofer_create_plan gofer_get_plan gofer_add_todo gofer_update_todo gofer_dispatch_todo gofer_plan_run gofer_ask_human gofer_list_templates gofer_register gofer_list_presence gofer_post_message gofer_poll_inbox gofer_wakeup_create gofer_wakeup_list gofer_wakeup_disable.
serve embeds a static SPA (the page itself needs no auth; its /v1/* calls do). Build and embed it with make web build; a bare go build serves a placeholder page without affecting the API. Pages: Home (service health, drivers/runners, escalations, jobs by status, schedules, projects, plus two metadata-db cards — Server DB: file + WAL size, page geometry, the busiest tables by row count; Sessions: totals by state, relay mode split, turns still waiting for a reply, the card links through to Sessions) / board / review queue (/review, REV-01) — jobs parked in needs_review with their acceptance material in one row (verify badge, commit count, usage, wait time; longest wait first), inline accept/reject and a top-bar count badge / job detail (live logs, diff, artifacts, pty attach; a job in needs_review, rejected, or done with require_review opens on a five-tab review panel — report / commits / diff / verify / usage — with accept/reject at the bottom) / Plans (todos, decisions; plan board — five-column kanban, drag a card to ready to dispatch it) / Sessions (relay switch, auto (idle Xm)) / Workflows / Schedules / Agents (configured agents and their detect status, with the online driver presence listed below it — a row opens the inbox at /agents/presence/:id, and the old /drivers + /drivers/:id links redirect there) / Runners / Projects (including "allow interactive jobs") / New job. The left rail groups the observing pages as Board, Review, Plans, Sessions, Workflows, Schedules and the fleet pages as Agents, Runners, Projects (Drivers is no longer a separate entry). Disable with serve --no-web or server.web_enabled: false.
/health is unauthenticated; everything under /v1/* requires Authorization: Bearer <token>. Error body: {"error":"…","detail":"…"}.
| group | main endpoints |
|---|---|
| projects / agents / roster | GET/POST /v1/projects, GET/PUT/DELETE /v1/projects/{key}, GET /v1/agents, GET /v1/runners, GET /v1/meta, GET /v1/metrics |
| templates | GET /v1/projects/{key}/templates, GET /v1/projects/{key}/templates/{name}?var=k=v (read-only; the detail endpoint returns the server's render of it) |
| jobs | POST/GET /v1/jobs, GET /v1/jobs/{id}, /logs/{stdout,stderr}, /stream (SSE), /events, /diff, /artifacts, POST …/cancel, POST …/resume, POST/GET …/wakeups, GET/PATCH/DELETE /v1/wakeups/{wid}, GET/DELETE …/worktree, POST …/attach-ticket, GET …/pty/sessions |
| interactions | POST/GET /v1/jobs/{id}/interactions, POST …/{iid}/answer, POST …/{iid}/punt, GET /v1/interactions |
| plans / decisions | POST/GET /v1/plans, GET /v1/plans/{id}, POST …/todos, POST …/jobs, POST …/run|pause|resume, POST/GET /v1/decisions, POST /v1/decisions/{id}/answer |
| session relay | GET/POST /v1/sessions, POST /v1/sessions/{sid}/heartbeat, …/relay, …/say, …/deliver, …/release-takeover, …/turns |
| workflows / schedules | POST/GET /v1/workflows, …/{id}/cancel, …/events, …/export; POST/GET /v1/schedules, …/enable, …/disable, …/run-now, …/rotate-token, POST /v1/schedules/{id}/trigger?token=… (unauthenticated, schedule token) |
| workers / tunnels | GET /v1/workers/connect (WS), /v1/workers/pty-connect, POST /v1/workers/{id}/reload, GET /v1/tunnels, /v1/tunnels/connect, /v1/workers/tunnel-connect |
POST /v1/jobs body (snake_case): project_key, agent, runner, prompt / cmd, cwd, timeout_sec, title, worker_id / worker_labels, interactive, worktree / worktree_base, plan_id, tags, sync / wait_timeout_sec, request_id (idempotency key), template / vars (a server-rendered task book).
Single machine (Linux/macOS):
export GOFER_CONFIG=~/.config/gofer/config.yaml
gofer init server --global && $EDITOR ~/.config/gofer/config.yaml
gofer project add demo-api --host-path /abs/demo-api --container-path /work/demo-api
gofer serve -d # daemon; log at <config-dir>/run/serve.logWindows: logon scheduled task in the desktop session — scripts/start.ps1 (ordinary window; -Elevated needs admin):
pwsh -File scripts\start.ps1 -Action up -ConfigDir 'D:/path/to/gofer' # register + start a logon task; local jobs run on YOUR desktop, as you
pwsh -File scripts\start.ps1 -Action upgrade [-Web] # make build first (server keeps running) → stop → swap serve-run\gofer.exe → start; previous exe kept as .prev
pwsh -File scripts\start.ps1 -Action status|logs|restart|stop|removeRuns as a scheduled task rather than a service on purpose: a service lives in session 0 and cannot drive the desktop, so --runner local GUI jobs (DTools / CODESYS / screenshots) fail there. Migrating off an old nssm service: runbook §7.
Container ↔ host: the container is a pure client (gofer init client, GOFER_SERVER_ADDR=http://host.docker.internal:8765), the host runs the server (and/or a worker); a job's --cwd resolves against the executing machine's project root, so never hard-code container paths in commands.
- Listening on
0.0.0.0:8765keeps the server reachable from containers; security relies on a mandatory token + network admission. Tighten to127.0.0.1for purely local use. - Mandatory token: startup is refused without one; an empty token needs an explicit
--allow-empty-token. Multiple caller tokens are compared in constant time andcaller_idis persisted to prevent spoofing. - Worker binding: a
worker_idis bound to its token; in POLICY mode projects are pushed by the server and the worker can only tighten viaroots+guards. - Execution boundaries: exec needs both the project's and the request's consent; cwd is confined to the project (safeJoin blocks
../); argv arrays, never shell strings;--worktreestays under the repo'stmp/gofer/wt; tunnel targets are allowlisted on the worker. - Logs: tokens / Authorization / nonces / payloads are never written; the log API serves only the last 256KB.
Codenames: codex-bridge (single codex + exec) → dev-agent-bridge (multi-agent/project registry + /v1 async jobs) → gofer (+ WS workers / label scheduling / sync & markdown submission / Web / MCP / SQLite / plans & decisions / session relay / tunnels / reconnect recovery / worktrees).
Designs and implementation plans live under
docs/(design/,plans/,runbook/).