hooks/lib/subagent.ts: CLAUDE_CODE_FORK_SUBAGENT is set in main sessions on Claude Code 2.1.26x+, muting nine hooks (fix + tests attached)
Summary
hooks/lib/subagent.ts treats CLAUDE_CODE_FORK_SUBAGENT === '1' as "this process is a fork subagent" (added in 7.40.4 for #1831). On Claude Code 2.1.26x+ (verified on 2.1.274, Linux) the harness exports that variable into every child process of the main interactive session, hooks included, so isSubagentContext() returns true in every primary session and nine consumers exit silently: LoadContext, LoadMemory, MemoryDeltaSurface, MemoryTurnStart, MemoryReviewFire, SystemChangeSurface, ISASync (session strip), ConfigEvalFire, KittyEnvPersist.
Net effect in a primary session: no session-start dynamic context, no hot-layer memory injection on any prompt, no / / ⚙️ lines, no ascent strip, and the memory reviewer never triggers. Nothing errors; MemoryHealthCheck stays at WARN (its surface-liveness check only assesses when curation is writing, and curation never starts because the reviewer never fires), and the line only surfaces on CRITICAL. It took four days to notice.
Evidence
- Live PostToolUse hook process on 2.1.274:
/proc/<pid>/environcarriesCLAUDE_CODE_FORK_SUBAGENT=1andCLAUDE_CODE_CHILD_SESSION=1; the parentclaudeprocess carries neither. isSubagentContext()in that environment →true; with onlyCLAUDE_CODE_FORK_SUBAGENTunset →false.- A real fork subagent (
Agentwithsubagent_type: "fork") has an env identical to the main session's (fork=1, child=1, noCLAUDE_AGENT_TYPE), so the env cannot separate the two. Its hook stdin, however, carries"agent_id": "…", "agent_type": "fork". MemoryTurnStart.hook.tsunder that env: 0 bytes of stdout before the fix; ~18 KB (memory block + line) after, and still 0 bytes when the stdin payload carriesagent_id.- Timeline on one node: last hook-fed line at the exact minute Claude Code went 2.1.258 → 2.1.272.
Preflight: UpstreamPreflight reports DEFECT-LIVE for hooks/lib/subagent.ts against main @ 5e2f2e8.
Proposed fix (diff attached)
Decide on the documented signal first. The hooks reference says that inside a subagent the stdin JSON carries agent_id and agent_type; callers that have parsed stdin pass it in, and the explicit env markers (CLAUDE_AGENT_TYPE, CLAUDE_CODE_SUBAGENT_NAME, CLAUDE_CODE_SUBAGENT_TYPE, CLAUDE_AGENT_SDK) remain as the no-stdin fallback. CLAUDE_CODE_FORK_SUBAGENT is dropped. Consumers that decided before reading stdin (MemoryTurnStart, MemoryReviewFire, ConfigEvalFire, KittyEnvPersist, LoadContext) now read first; SystemChangeSurface and ISASync pass the input they already had. A bun test suite covers the negative control (2.1.274 env, main-session stdin → false) and the positive ones (raven / fork stdin → true).
Two follow-ons in the same change on our fork, offered in case they are useful: MemoryHealthCheck gains a hook-liveness check keyed on processed prompts vs the surface heartbeat (evidence that always exists, rather than curation writes that may never exist), and MemoryDeltaSurface surfaces for a WARN that has persisted 24 h, not only for CRITICAL.
diff --git i/LifeOS/install/hooks/lib/subagent.ts w/LifeOS/install/hooks/lib/subagent.ts
index b3a031e..6858b7a 100644
--- i/LifeOS/install/hooks/lib/subagent.ts
+++ w/LifeOS/install/hooks/lib/subagent.ts
@@ -1,4 +1,5 @@
/**
+ * @version 2.0.0
* subagent.ts — the single answer to "am I running inside a subagent?"
*
* Eight hooks each carried their own copy of this test, and the copies had
@@ -12,25 +13,47 @@
* because nothing compares them; one function is the fix, not a rule telling
* people to keep nine copies in step.
*
- * The test is the UNION of every known marker. That is strictly safer than any
- * single family: a missed subagent leaks main-session context (and a duplicate
- * surface line) into a delegate, while a false positive would suppress context
- * in a main session. Verified 2026-07-28 in a main session — every marker below
- * is unset, so the union cannot false-positive there.
+ * v2.0.0 (2026-09-18, INC-20260918-hook-mute): the documented signal comes
+ * first. Claude Code puts `agent_id` (and `agent_type`) in the hook's stdin
+ * JSON when, and only when, the hook runs inside a subagent (hooks reference:
+ * "When running with --agent or inside a subagent, two additional fields are
+ * included"). Callers that have parsed their stdin pass it in; the env markers
+ * remain as the fallback for callers with no stdin.
+ *
+ * The one marker that is GONE is `CLAUDE_CODE_FORK_SUBAGENT`. It was added
+ * for public issue #1831 (forks set only that marker). From Claude Code
+ * 2.1.26x the harness exports it into EVERY child of the main interactive
+ * session — hooks included, verified on a live PostToolUse hook process on
+ * 2.1.274 — so keying on it muted nine hooks in every primary session for
+ * four days before anyone noticed. A false positive here suppresses the
+ * session-start context, the hot-layer memory, the //⚙️ lines, the ascent
+ * strip and the memory reviewer, silently. Never key on an undocumented
+ * env variable again; the stdin field is the contract.
*/
-/** True when this process is a subagent/delegate rather than the main session. */
-export function isSubagentContext(): boolean {
+/** The two fields Claude Code adds to hook stdin inside a subagent. */
+export interface SubagentIdentity {
+ agent_id?: unknown;
+ agent_type?: unknown;
+}
+
+/**
+ * True when this process is a subagent/delegate rather than the main session.
+ *
+ * @param input the hook's parsed stdin JSON, when the caller has it. A hook
+ * that has not read stdin passes nothing and gets the env-only answer.
+ */
+export function isSubagentContext(input?: SubagentIdentity | null): boolean {
+ if (input && typeof input === 'object') {
+ if (typeof input.agent_id === 'string' && input.agent_id.length > 0) return true;
+ if (typeof input.agent_type === 'string' && input.agent_type.length > 0) return true;
+ }
const projectDir = process.env.CLAUDE_PROJECT_DIR || '';
return Boolean(
projectDir.includes('/.claude/Agents/') ||
process.env.CLAUDE_AGENT_TYPE ||
process.env.CLAUDE_CODE_SUBAGENT_NAME ||
process.env.CLAUDE_CODE_SUBAGENT_TYPE ||
- // Forked subagents set ONLY the fork marker — none of the above.
- // Without it, 8 hook consumers re-inject main-session context into
- // forks that inherited it via cache. (public issue #1831, @DRAZY)
- process.env.CLAUDE_CODE_FORK_SUBAGENT === '1' ||
process.env.CLAUDE_AGENT_SDK === '1',
);
}Filed as issue-plus-diff per our usual shape; no PR. Happy to adjust if you would rather keep the env marker for some harness version we have not seen.
Source: danielmiessler/LifeOS