# 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:

```bash
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.** `--author` alone leaves the committer as the owner, and
  `git log` shows 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:

```js
{ 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`:

```ts
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:

```ts
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 the `sessions` map. 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's `AGENT.md` into the *first user
  message*. There is no `systemPrompt`, 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:

1. **`src/servers/sidecar/protocol.ts`** — add an optional `gitIdentity: { name: string; email: string }`
   to `ClaudeSpawnStreamingParams` (the type begins at line 170). Optional so every existing caller is
   untouched.
2. **`claude-manager.ts:315`** — build `cleanEnv` per session rather than once, merging the identity in
   as all four variables when present:
   ```ts
   ...(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,
   }),
   ```
3. **`agent-runner.ts:144`** — populate it from `agent.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 `dirName` the right identity, or should an agent's `AGENT.md` declare 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?
