Not built, and deliberately so — this is the idea as it stood, with the spawn path read at dc6b623 so
the next person does not have to re-derive it.
The obvious approach is wrong here and the document leads with why: officer-agent is one process
holding many sessions, so a PM2 env block or anything set in user-instance.ts is shared by every agent
on the box and cannot distinguish them. The injection point that does work is claude-manager.ts:315,
where cleanEnv is built once today but is already a per-query() option.
Recorded alongside it: opencode cannot do this at all since the serve migration, because no process is
spawned per turn; and per-agent identity is attribution, not isolation — agents share one working tree,
so two of them in one repo will still fight over index.lock. That is the larger problem and it is named
rather than solved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.0 KiB
Per-agent git identity
Status: idea, not implemented. Nothing in this document has been built. Written 2026-08-10 from a
read of the live spawn path; the file:line references were verified against dc6b623.
The goal
A team of agents works on this project, sometimes several of them in the same repository at once. Each
one should commit under its own identity, so git log answers "which agent wrote this" without anybody
having to remember to say so.
Today it cannot. Every agent commits as the owner, because every agent is the owner as far as the OS is concerned.
How git identity can be overridden at all
Identity is unverified metadata, not authentication — a default, never a constraint. Anyone who can commit can claim any name and email, by any of:
git -c user.name=X -c user.email=x@y.z commit # per-invocation config
git commit --author="X <x@y.z>" # author only; committer stays whoever ran it
GIT_AUTHOR_NAME=X GIT_AUTHOR_EMAIL=x@y.z \
GIT_COMMITTER_NAME=X GIT_COMMITTER_EMAIL=x@y.z git commit # env; both identities
Precedence: --author > GIT_AUTHOR_* env > -c user.email > local config > global config.
Two consequences that shape the design below:
- Author and committer are different fields.
--authoralone leaves the committer as the owner, andgit logshows only the author by default. Set both, or the attribution is half-fiction. To read both:git log --format='%an <%ae> | %cn <%ce>'. - Environment beats instruction. Telling an agent "commit as X" in its prompt is a rule it can
forget.
GIT_AUTHOR_*in the process environment applies to every git invocation in that process whether or not anyone remembered. The whole point is to make the identity unforgettable rather than well-intentioned, so this belongs in the environment.
Why the obvious approach does not work here
The first instinct is a PM2 env block per agent, or setting process.env.GIT_AUTHOR_NAME in the
sidecar at boot. Both are wrong on this platform, and for the same reason:
officer-agent is one process running many concurrent sessions. sessions is a
Map<string, PersistentSession> (src/servers/sidecar/claude/claude-manager.ts:213); each session owns
its own long-lived query() and its own warm claude child, reused across turns and collected after 30
minutes idle. A singleton lockfile enforces one sidecar per owner (user-instance.ts:95-98).
So a process-level variable — whether from PM2, from .env, or assigned in user-instance.ts — is
shared by every agent and every session on the box. It can say "an agent did this". It cannot say which.
The ecosystem.config.cjs entry is bare anyway, which is worth recording since it looks like a place
where env might already be happening:
{ name: 'officer-agent', script: 'bun', args: 'run src/servers/sidecar/claude/user-instance.ts', watch: false },
No env, no cwd, no interpreter — for any app in the file.
What the spawn path actually looks like
The live path is the Agent SDK, not Bun.spawn. claude-manager.ts:320-360:
const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>,
options: {
cwd: params.cwd ?? HOST_HOME,
permissionMode: 'bypassPermissions',
pathToClaudeCodeExecutable: CLAUDE_BIN,
env: cleanEnv as Record<string, string>,
...
},
});
cleanEnv is built once, at claude-manager.ts:315, by destructuring three keys back out of the
sidecar's own environment:
const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;
That is the whole story: the child gets the sidecar's full process.env minus the three nested-session
guards, and nothing is added per turn.
This is the good news. env is already a per-query() option. It is built once today, but there
is no structural reason it has to be — which makes claude-manager.ts:315 the single injection point
for everything below.
What identity exists today
Almost none, and none of it at the OS level.
sessionKey— officer's uuid, the key in thesessionsmap. Reaches the child only as a transport field on the pushed message.- Agent name and persona are prompt-only.
buildAgentPrompt(src/servers/api/agents/agent-runner.ts:71-79) inlines the agent'sAGENT.mdinto the first user message. There is nosystemPrompt, no--agents, no per-agent settings file. - The one durable per-agent handle is the working directory:
getAgentRunsDir(agent.dirName)(agent-runner.ts:144), deliberately shared across all runs of that agent so the CLI groups their transcripts.
grep -rn "GIT_AUTHOR\|GIT_COMMITTER" src/ returns nothing. Verified. Any commit an agent makes today
is attributed to whatever ~/.gitconfig says — the human owner, identically for every agent.
The proposed change
Three edits, all on the claude path:
src/servers/sidecar/protocol.ts— add an optionalgitIdentity: { name: string; email: string }toClaudeSpawnStreamingParams(the type begins at line 170). Optional so every existing caller is untouched.claude-manager.ts:315— buildcleanEnvper session rather than once, merging the identity in as all four variables when present:...(params.gitIdentity && { GIT_AUTHOR_NAME: params.gitIdentity.name, GIT_AUTHOR_EMAIL: params.gitIdentity.email, GIT_COMMITTER_NAME: params.gitIdentity.name, GIT_COMMITTER_EMAIL: params.gitIdentity.email, }),agent-runner.ts:144— populate it fromagent.dirName, next to where the cwd pin is already derived from the same field.
Chat sessions (api/chat/websocket.ts) would pass nothing and keep committing as the owner, which is
almost certainly right: a chat turn is the human driving directly.
Use a domain that is actually controlled — <dirName>@officer.dev — so Gitea can be made to map or
deliberately not-map these authors later.
Known limitations of the proposal
opencode cannot do this at all
Since the serve migration (a3dbda7, phase D) there is no process spawned per turn. One shared
opencode serve starts at sidecar boot (src/servers/sidecar/opencode/index.ts:112-116) with no env
key — full inheritance — and turns are driven over HTTP against it, with per-turn cwd carried as a
request header (serve-runner.ts:155). Every session shares that one process environment.
Per-agent git identity on the opencode path therefore requires either a serve per agent, or an upstream API field. Neither is a small change, and this document does not propose one.
Attribution is not isolation, and isolation is the real problem
There is no filesystem isolation between agents. They share one real HOME
(HOME_DIR=/home/pastilhas), one ~/.claude, one credential store; user-instance.ts:75-78 says this
outright, and it is the stated reason chat is an execution capability that can never be granted.
grep -ril worktree src/ returns nothing — worktrees are used nowhere.
cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely.
So two agents told to work on the same repository will share one working tree: fighting over
index.lock, staging each other's half-finished edits, interleaving commits. Per-agent identity makes
that legible after the fact. It does nothing to prevent it.
If agents are genuinely to work the same repo concurrently, the isolation question is the larger and more urgent one — a worktree or a clone per agent — and per-agent identity composes naturally with it (a worktree per agent is also the cleanest place to put a per-worktree git identity).
If worktrees do arrive, note this trap
git worktree shares one .git/config, so git config --local user.email in one worktree changes it
for all of them. Per-worktree config needs extensions.worktreeConfig true and then
git config --worktree user.email …. Environment variables sidestep the whole issue, which is another
argument for the env approach above.
Deliberately not proposed
- Signing. Per-agent SSH signing keys would make attribution unforgeable rather than conventional.
For a trusted local fleet where every agent already runs as the owner with
permissionMode: 'bypassPermissions', an agent that wanted to forge another's identity has far easier routes. Revisit only if agents stop being equally trusted. Co-Authored-By:trailers. Useful when a human and an agent genuinely share a commit, and both Gitea and GitHub attribute them. Orthogonal to this, and a prompt-level convention rather than an environment one.
Open questions
- Should pipeline steps (
pipeline-executor.ts) get an identity too, or only named agents? - Is
dirNamethe right identity, or should an agent'sAGENT.mddeclare its own name and email — so the identity is authored where the persona is, rather than derived from a directory? - Does anything downstream — Gitea webhooks, activity feeds, the dashboards — assume commits belong to the owner and break when they do not?