Summary
LifeOS/install/hooks/EventLogger.hook.ts (@version 1.0.3) drops the only two fields that say who made a tool call. Claude Code adds agent_id and agent_type to the hook payload when — and only when — the call originates from a subagent; in the main session both keys are absent. The logger never reads them, so every delegated action is written to tool-activity.jsonl indistinguishable from work the main session did itself.
Evidence
Payload captured from a live PreToolUse/PostToolUse hook run (values redacted, shape verbatim):
The critical part is what is not different: session_id, transcript_path, cwd and prompt_id are byte-identical between the main session and its subagents. agent_type is therefore the only signal distinguishing the two callers — anything else (notably transcript_path, which some hooks use for this) cannot work.
Why it matters
tool-activity.jsonl is the ground-truth audit trail the rest of the system reads. Today it answers "what happened" but silently mis-answers "who did it": a file written by a delegated agent is recorded exactly like a file written by the operator's own session. That breaks three things at once:
- attribution — a bad write cannot be traced to the agent that made it;
- per-caller policy — no downstream consumer can build "delegates may not touch X" rules on top of the log, because the log has thrown the caller away;
- incident review — after an unexpected change, the log cannot narrow the search to delegated work.
The fields are already in the payload. Nothing needs to be plumbed; they just need to survive.
Root cause
Two event literals build the record from a hand-picked field list, and neither list includes the attribution:
interface ToolUseInput (line 91) does not declare the fields, so handlePostToolUse cannot see them, and the const event = { … } literal (line 196) does not emit them.
- The same for the failure path:
interface ToolFailureInput (line 267) and the ToolFailureEvent literal it feeds.
Suggested fix
Additive, ~10 lines, no behavior change for main-session events (the keys simply do not appear, exactly as today).
// near interface ToolUseInput (line 91)
interface ToolUseInput {
session_id: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_response?: unknown;
agent_id?: string; // present ONLY when the caller is a subagent
agent_type?: string; // idem
}
/**
* NO DEFAULT, EVER: a field that names WHO acted must not be invented.
* An absent key means unattributed, and the record simply does not carry it —
* writing a name we did not read would turn a guess into evidence.
*/
function agentFields(data: { agent_id?: unknown; agent_type?: unknown }): {
agent_id?: string; agent_type?: string;
} {
const out: { agent_id?: string; agent_type?: string } = {};
if (typeof data?.agent_id === 'string' && data.agent_id.length > 0) out.agent_id = data.agent_id;
if (typeof data?.agent_type === 'string' && data.agent_type.length > 0) out.agent_type = data.agent_type;
return out;
}
then spread it into the two literals:
// line ~196, inside handlePostToolUse
const event = {
timestamp: getISOTimestamp(),
event: 'tool_use',
source: 'tool-activity',
type: 'tool_use',
session_id: data.session_id,
...agentFields(data), // <-- added
tool_name: toolName,
tool_input_preview: inputPreview,
...(groundTruth ? { ground_truth: groundTruth } : {}),
};
and the same one-line spread in the ToolFailureEvent literal (add the two optional fields to ToolFailureInput / ToolFailureEvent alongside it).
Using a conditional spread rather than agent_id: data.agent_id ?? 'main' is the load-bearing detail: a defaulted actor name is a guess wearing the costume of a fact, and it would be worse than no field at all.
Reproduction
# 1. subagent-shaped payload → the two keys should survive into the record
echo '{"hook_event_name":"PostToolUse","session_id":"repro","tool_name":"Read",
"agent_id":"ab0b254f0e22d3a35","agent_type":"Explore","tool_input":{"file_path":"x"}}' \
| bun hooks/EventLogger.hook.ts
tail -1 "$LIFEOS_DIR/MEMORY/OBSERVABILITY/tool-activity.jsonl"
# actual: no agent_id / agent_type
# expected: both fields present
# 2. negative pole — main-session payload must NOT gain the fields
echo '{"hook_event_name":"PostToolUse","session_id":"repro","tool_name":"Read",
"tool_input":{"file_path":"x"}}' | bun hooks/EventLogger.hook.ts
tail -1 "$LIFEOS_DIR/MEMORY/OBSERVABILITY/tool-activity.jsonl"
# expected: neither key present (absent, not null, not "main")
Tested on @version 1.0.3 of the shipped hook. Happy to send this as a PR if the shape above looks right.
Summary
LifeOS/install/hooks/EventLogger.hook.ts(@version 1.0.3) drops the only two fields that say who made a tool call. Claude Code addsagent_idandagent_typeto the hook payload when — and only when — the call originates from a subagent; in the main session both keys are absent. The logger never reads them, so every delegated action is written totool-activity.jsonlindistinguishable from work the main session did itself.Evidence
Payload captured from a live
PreToolUse/PostToolUsehook run (values redacted, shape verbatim):The critical part is what is not different:
session_id,transcript_path,cwdandprompt_idare byte-identical between the main session and its subagents.agent_typeis therefore the only signal distinguishing the two callers — anything else (notablytranscript_path, which some hooks use for this) cannot work.Why it matters
tool-activity.jsonlis the ground-truth audit trail the rest of the system reads. Today it answers "what happened" but silently mis-answers "who did it": a file written by a delegated agent is recorded exactly like a file written by the operator's own session. That breaks three things at once:The fields are already in the payload. Nothing needs to be plumbed; they just need to survive.
Root cause
Two event literals build the record from a hand-picked field list, and neither list includes the attribution:
interface ToolUseInput(line 91) does not declare the fields, sohandlePostToolUsecannot see them, and theconst event = { … }literal (line 196) does not emit them.interface ToolFailureInput(line 267) and theToolFailureEventliteral it feeds.Suggested fix
Additive, ~10 lines, no behavior change for main-session events (the keys simply do not appear, exactly as today).
then spread it into the two literals:
and the same one-line spread in the
ToolFailureEventliteral (add the two optional fields toToolFailureInput/ToolFailureEventalongside it).Using a conditional spread rather than
agent_id: data.agent_id ?? 'main'is the load-bearing detail: a defaulted actor name is a guess wearing the costume of a fact, and it would be worse than no field at all.Reproduction
Tested on
@version 1.0.3of the shipped hook. Happy to send this as a PR if the shape above looks right.