danielmiessler / danielmiessler/LifeOS

isSubagentContext() is broken both ways since install: true in every main session (kills the memory pipeline), and never true inside a subagent

Open
#2,075 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
19k
Forks
2.5k
Avg merge
8d 17h
Merged PRs (30d)
1

Description

# `settings.system.json` sets `CLAUDE_CODE_FORK_SUBAGENT=1`, so `isSubagentContext()` is true in every main session

**Version:** LifeOS 7.40.4 (fresh install, migrating from PAI 4.0.3)

## Summary

The shipped `settings.system.json` sets `CLAUDE_CODE_FORK_SUBAGENT` as a global
env toggle. `hooks/lib/subagent.ts` reads that same variable as a runtime marker
meaning "I am a forked subagent". Both ship in the same release, so on a stock
install `isSubagentContext()` returns `true` in the **main** session, and the
seven hooks that guard on it exit immediately.

The memory pipeline is silently dead from the first session onward.

## Evidence

`settings.system.json` (shipped):

```json
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
"CLAUDE_CODE_FORK_SUBAGENT": "1",
"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": "20",
```

`hooks/lib/subagent.ts`:

```ts
process.env.CLAUDE_CODE_FORK_SUBAGENT === '1' ||
```

In a main session with the stock settings file, `env | grep CLAUDE_` shows
`CLAUDE_CODE_FORK_SUBAGENT=1` and no `CLAUDE_CODE_SUBAGENT_NAME` /
`CLAUDE_CODE_SUBAGENT_TYPE`. `isSubagentContext()` returns `true`.

## Affected hooks

All seven bail on this guard as their first statement:

`MemoryTurnStart`, `LoadMemory`, `MemoryDeltaSurface`, `MemoryReviewFire`,
`SystemChangeSurface`, `ISASync`, `ConfigEvalFire`.

## Observed symptoms

- `MemoryReviewFire` never ticks, so `review-state.json` stays at
`turn_count_since_last_review: 1` and `last_review_at: null` forever. The
8-turn threshold is never reached and the autonomic reviewer never runs.
- No per-session file is ever written under `MEMORY/STATE/memory-review/`.
- `MEMORY/OBSERVABILITY/reviewer-runs/` is never created, so
`MemoryHealthCheck` CHECK 6 warns `reviewer-runs/ directory does not exist
yet` indefinitely. **This warning is the only user-visible symptom**, and it
reads as a benign not-yet-run notice rather than a dead subsystem.
- No memory-delta or system-change surface lines are ever emitted.
- `ISASync` emits no ascent-delta blocks.

## Repro

1. Fresh LifeOS 7.40.4 install with the shipped `settings.system.json`.
2. Run a main session for more than 8 turns.
3. `cat $LIFEOS_DIR/MEMORY/OBSERVABILITY/review-state.json` — the turn count
has not moved and `last_review_at` is still `null`.

## Why each half looks correct alone

The fork marker was added to `subagent.ts` deliberately (referenced in-file as
public issue #1831): forked subagents set only that marker, and without it eight
consumers re-inject main-session context into forks. That is correct when the
harness alone sets the variable. It breaks when the same release also sets it
globally as a feature switch.

The comment in `subagent.ts` states the union "cannot false-positive" in a main
session, verified 2026-07-28. That verification predates the toggle landing in
the shipped settings file.

## Suggested fix

Separate the switch from the marker. Options, roughly in order of preference:

1. Drop `CLAUDE_CODE_FORK_SUBAGENT` from the union in `isSubagentContext()` and
detect forks by a marker the harness sets per-process, not one the user's
settings set globally.
2. If the fork marker must stay in the union, stop setting it in the shipped
`settings.system.json` and let the harness set it.
3. Failing both, gate the fork branch on the absence of a main-session marker
(e.g. treat it as a fork only when `CLAUDE_CODE_SESSION_ID` is absent, or
when a subagent name/type is also present).

A regression test worth adding either way: assert `isSubagentContext() === false`
under the shipped `settings.system.json` env, which is exactly the case the
in-file comment claims is verified.

---

## Update after applying fix option 2 — the bug is deeper than the toggle

I removed `CLAUDE_CODE_FORK_SUBAGENT` from my local settings (option 2 above) and
restarted. The main session recovered: the memory hooks fire, `MemoryReviewFire`
ticks on every Stop, the per-session state file is written, and the surface lines
appear. So option 2 does fix the reported symptom.

But it also reveals that **`isSubagentContext()` never detected subagents at all**
on this harness version. It is not that the toggle broke fork detection; there was
no working fork detection to break.

### Evidence

I spawned two subagents and had each report the guard plus all six env markers:

| context | `isSubagentContext()` | env markers set |
|---|---|---|
| main session | `false` (correct) | none |
| fork subagent | `false` (**wrong**) | none |
| plain subagent (`general-purpose`) | `false` (**wrong**) | none |

Every marker in the union is unset in a fork: `CLAUDE_PROJECT_DIR`,
`CLAUDE_AGENT_TYPE`, `CLAUDE_CODE_SUBAGENT_NAME`, `CLAUDE_CODE_SUBAGENT_TYPE`,
`CLAUDE_CODE_FORK_SUBAGENT`, `CLAUDE_AGENT_SDK`.

The harness signals subagency in the **hook stdin payload**, not the environment.
LifeOS's own `EventLogger.hook.ts` already reads it:

```ts
...(typeof data.agent_id === 'string' ? { agent_id: data.agent_id } : {}),
...(typeof data.agent_type === 'string' ? { agent_type: data.agent_type } : {}),
```

In `MEMORY/OBSERVABILITY/tool-activity.jsonl` for one session, exactly 2 of 248
entries carry `agent_type`, and they are precisely the two subagent tool calls
(`"fork"` and the named `general-purpose` agent). All 246 main-session entries
have neither field. So the signal is present and reliable, just not in `process.env`.

Note this is the same class of gap already recorded in `HookSystem.md` for
`SubagentStart` ("payload omits `subagent_type` / `description` / `prompt`"), which
is why lifecycle tracking moved to the `PreToolUse:Agent` boundary. The env-marker
approach in `subagent.ts` has the mirror-image problem.

### Consequence

Of the seven guard consumers, only two can execute inside a subagent at all:

| hook | event | reachable in a subagent? |
|---|---|---|
| `MemoryTurnStart` | UserPromptSubmit | no — a subagent submits no user prompt |
| `MemoryReviewFire` | Stop | no — a subagent's finish is `SubagentStop` |
| `LoadMemory`, `MemoryDeltaSurface`, `SystemChangeSurface` | imported by the above | no |
| **`ISASync`** | PostToolUse[Write/Edit/MultiEdit] | **yes** |
| **`ConfigEvalFire`** | PostToolUse[Write/Edit/MultiEdit] | **yes** |

So any subagent that writes or edits a file runs `ISASync` and `ConfigEvalFire`
unguarded. `ISASync` computes and emits ``, which the system
prompt states subagents must never receive.

That narrow blast radius is presumably why this went unnoticed: the guard's failure
is invisible unless a delegate writes a file and you inspect what it received.

### Suggested fix, revised

Options 1 and 3 in the original report cannot work, because there is no
per-process env marker for the harness to set. Read the payload instead. Adding a
second helper avoids changing the existing signature or dropping the env union:

```ts
/** True when a parsed hook payload came from a subagent tool call. */
export function isSubagentPayload(p: unknown): boolean {
const o = (p ?? {}) as Record;
return typeof o.agent_id === 'string' || typeof o.agent_type === 'string';
}
```

Then OR it into the guard at the two PostToolUse sites that already parse the
payload. The other five consumers are unaffected.

Caveat: `agent_id` / `agent_type` are not in the documented payload shape
(`HookSystem.md` "Hook Input (stdin)" and "Hook Data Payloads by Event Type" list
neither), so they are undocumented harness fields. They are observably present and
already relied on by `EventLogger`, but a maintainer should confirm they are stable
before this is depended on more widely.

### Two doc gaps that made this expensive to diagnose

1. Nothing in `DOCUMENTATION/` mentions `isSubagentContext` or `hooks/lib/subagent.ts`.
`grep -rn 'isSubagentContext\|subagent\.ts' DOCUMENTATION/` returns zero hits,
despite seven hooks depending on it. Each restarted session has to re-derive the
contract from source.
2. The in-file comment asserts the union "cannot false-positive" in a main session,
"verified 2026-07-28". Worth adding the inverse assertion as a test too: that the
guard returns `true` inside a fork. That test would have failed from the start.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with hooks/lib/subagent.ts and the two PostToolUse guard consumers, then compare their parsed stdin payloads with EventLogger.hook.ts. Check settings.system.json and the hook payload descriptions in HookSystem.md. Done means main sessions still run guarded hooks, subagent write/edit calls are guarded, and tests cover both contexts using the observed payload markers.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.