44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
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 permission 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?