-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathharness-context.ts
More file actions
237 lines (226 loc) · 10 KB
/
Copy pathharness-context.ts
File metadata and controls
237 lines (226 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
// SPDX-License-Identifier: Apache-2.0
/**
* Harness session-id discovery for cognitive-primitive MCP servers (D083).
*
* Extends D078's ATRIB_CONTEXT_ID env-var fallback. D078 made the four
* cognitive-primitive MCP servers (@atrib/emit, @atrib/recall, @atrib/trace,
* @atrib/summarize) honor process.env.ATRIB_CONTEXT_ID when a tool call
* omits the context_id argument.
*
* D083 v1 added a harness env-var fallback (e.g. CLAUDE_CODE_SESSION_ID).
* That fix closed the orphan-singleton class for harnesses that spawn MCP
* children per-session (inheriting the per-session env). It did NOT close
* it for harnesses that spawn MCP children ONCE at process startup, before
* any session exists. The per-session env never propagates to the
* already-running child. Claude Code is the canonical example: MCP
* children are spawned at Claude Code launch; CLAUDE_CODE_SESSION_ID is
* created later, per-conversation, and never reaches the child's env.
*
* D083 v2 adds a SECOND fallback per discovery entry: a state file the
* harness writes from a session-aware context (typically a SessionStart
* hook). The MCP child reads the file each call. The premise:
* - the harness has a session-aware writer (hook, callback) that DOES
* have the per-session id in its env
* - the MCP child has no such surface, but can read a file
* - the writer translates env -> file; the reader translates file -> id
*
* The file path is supplied as a thunk so it can resolve dynamically
* (e.g., per-parent-PID to isolate concurrent harness instances).
*
* D083 v3 adds a host-profile state file for long-lived primitive hosts
* whose parent PID is not the interactive harness. The motivating case is
* Codex's launchd-owned `atrib-primitives` Streamable HTTP runtime:
* process.ppid is 1, while Codex hooks write the active session under the
* app-server PID. The runtime can still declare `ATRIB_AGENT=codex`, so the
* reader also checks `~/.claude/state/active-session-id-codex` when a safe
* profile name is present.
*
* Per D078's precedent the fallback is silent: harness state files
* represent the spawning host's declared session scope, not a
* misconfiguration. Missing files, parse errors, and stat failures all
* produce undefined (not exceptions) so callers see the same shape as
* "neither set."
*/
import { readFileSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
const HEX_32 = /^[0-9a-f]{32}$/
const SAFE_PROFILE = /^[A-Za-z0-9._-]{1,64}$/
/** Maximum bytes to read from a fallback file. A 32-hex or UUID-form
* session id is < 40 bytes; anything larger is suspicious garbage. */
const FALLBACK_FILE_MAX_BYTES = 128
/** A single harness's discovery rule. */
export interface HarnessDiscovery {
/**
* Documented env var name the harness exposes to MCP child processes.
* Empty string means "no env-var path; file only" (rare).
*/
envVar: string
/**
* Optional thunk returning a state file path. The file is expected to
* contain a single line whose `parse()` produces a 32-hex context_id.
* Function form (not static string) supports dynamic resolution like
* per-parent-PID isolation (process.ppid).
*
* The writer responsibility lives outside this library: typically a
* harness-specific SessionStart hook in the operator's hooks directory.
* The file convention is documented per discovery entry.
*/
fallbackFile?: (env?: NodeJS.ProcessEnv) => string
/**
* Parse a candidate value (from env or file) into a 32-hex context_id,
* or return null if the value cannot produce one. Pure function; no
* side effects. Whitespace already trimmed by the caller.
*/
parse(value: string): string | null
}
/**
* Registered harnesses, ordered most-canonical first. Adding a new entry
* here is the implementation step for a new §9 integration pattern that
* lands harness-aware context discovery; the ADR governing each entry
* should be cross-referenced from the comment.
*/
export const KNOWN_HARNESS_DISCOVERIES: readonly HarnessDiscovery[] = [
// Claude Code: parent process exposes CLAUDE_CODE_SESSION_ID as a UUID
// (e.g. "38af29c4-fc3a-4f88-8fec-392501b8a0a9") to HOOK subprocesses
// (PreToolUse, SessionStart, etc.). The env var is NOT in scope for
// MCP children spawned at Claude Code launch, so the file fallback is
// required for the MCP child path.
//
// File convention: ~/.claude/state/active-session-id-<claude-code-pid>
// Writer: SessionStart hook (overwrites on every session start).
// The hook has CLAUDE_CODE_SESSION_ID in env and process.ppid =
// Claude Code PID. The MCP child has process.ppid = Claude Code
// PID. Same key on both sides.
// Content: the reference writer writes the raw UUID with dashes (one
// line). parse() ALSO accepts the already-stripped 32-hex form
// for hand-edits or alternate writers, but the canonical write
// shape is the dashed UUID for operator-readability.
// Per-PID keying isolates concurrent Claude Code instances (each
// spawns its own MCP children + its own SessionStart hook).
// Limitation: a single Claude Code instance serving multiple sessions
// in sequence (e.g. via /clear) overwrites this file each time;
// in-process MCP children read the most-recent session id.
// If the agent needs to disambiguate per-call, it MUST thread
// context_id explicitly. This is acceptable because Claude Code
// today serves one active session per instance at a time.
{
envVar: 'CLAUDE_CODE_SESSION_ID',
fallbackFile: claudeCodeParentProcessFile,
parse: parseUuidOrHexSessionId,
},
// Codex app server: hook subprocesses expose the active thread id as
// CODEX_THREAD_ID. Per-session spawned children can inherit it directly.
// Long-lived primitive hosts usually cannot, so the profile fallback entry
// below covers the launchd-owned Streamable HTTP runtime.
{
envVar: 'CODEX_THREAD_ID',
parse: parseUuidOrHexSessionId,
},
// Host-profile fallback for long-lived local runtimes that are not parented
// by the interactive app process. The reader uses ATRIB_ACTIVE_SESSION_PROFILE
// first, then ATRIB_AGENT. The matching hook writer maintains
// ~/.claude/state/active-session-id-<profile>. This is intentionally after
// concrete harness env vars so explicit per-session env still wins.
{
envVar: '',
fallbackFile: activeSessionProfileFile,
parse: parseUuidOrHexSessionId,
},
] as const
function parseUuidOrHexSessionId(value: string): string | null {
const candidate = value.replace(/-/g, '').toLowerCase()
return HEX_32.test(candidate) ? candidate : null
}
function claudeCodeParentProcessFile(): string {
if (process.ppid <= 1) {
throw new Error('no interactive parent process')
}
return join(homedir(), '.claude', 'state', `active-session-id-${process.ppid}`)
}
function activeSessionProfileFile(env: NodeJS.ProcessEnv = process.env): string {
const profile = env['ATRIB_ACTIVE_SESSION_PROFILE'] ?? env['ATRIB_AGENT']
if (!profile || !SAFE_PROFILE.test(profile)) {
throw new Error('no safe active-session profile')
}
return join(homedir(), '.claude', 'state', `active-session-id-${profile}`)
}
/**
* Read up to FALLBACK_FILE_MAX_BYTES from a fallback state file. Returns
* the trimmed contents on success, null on any error (missing file, read
* failure, oversize, etc.). Never throws.
*/
function readFallbackFile(path: string): string | null {
try {
const s = statSync(path)
if (!s.isFile()) return null
if (s.size > FALLBACK_FILE_MAX_BYTES) return null
const raw = readFileSync(path, 'utf8')
const trimmed = raw.trim()
return trimmed.length > 0 ? trimmed : null
} catch {
return null
}
}
/**
* Resolve an effective context_id from environment and harness state
* files, applying precedence:
*
* 1. ATRIB_CONTEXT_ID env (D078; explicit operator/harness intent)
* 2. For each KNOWN_HARNESS_DISCOVERIES entry, in order:
* 2a. discovery.envVar in env (D083 v1; per-session-spawn harnesses)
* 2b. discovery.fallbackFile() readable + parseable (D083 v2;
* startup-spawn harnesses like Claude Code MCP children)
* 3. undefined (caller falls through to its own resolution chain;
* typically synthesizes a fresh genesis chain_root + warning)
*
* Returns a validated 32-hex string or undefined. Never throws.
*
* The env-vs-file order within each discovery favors env when both are
* set: env is more immediate (set by the process spawner explicitly)
* while file is a fallback for cases env can't reach. If a harness
* intentionally overrides via env, that should win over a stale file.
*/
export function resolveEnvContextId(env: NodeJS.ProcessEnv = process.env): string | undefined {
const explicit = env['ATRIB_CONTEXT_ID']
if (explicit && HEX_32.test(explicit)) return explicit
for (const discovery of KNOWN_HARNESS_DISCOVERIES) {
// Env-var path. parse() may throw (a buggy or future discovery entry
// could ship a parser that asserts); catch so the silent-failure
// contract holds regardless of registry-entry quality.
if (discovery.envVar !== '') {
const value = env[discovery.envVar]
if (value !== undefined) {
try {
const parsed = discovery.parse(value)
if (parsed) return parsed
} catch {
// fall through to file path; next discovery; or undefined
}
}
}
// File-fallback path. Both the path-thunk and parse() are
// try-wrapped so any registry-entry exception falls through to
// the next discovery or to undefined.
if (discovery.fallbackFile !== undefined) {
let path: string | null = null
try {
path = discovery.fallbackFile(env)
} catch {
path = null
}
if (path !== null) {
const raw = readFallbackFile(path)
if (raw !== null) {
try {
const parsed = discovery.parse(raw)
if (parsed) return parsed
} catch {
// fall through
}
}
}
}
}
return undefined
}