switchroom 0.13.47 → 0.13.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse hook — when the agent first touches a file in a code repo
4
+ * outside its workspace, locate that repo's CLAUDE.md (or AGENTS.md /
5
+ * AGENT.md) and inject it as additionalContext on this tool call.
6
+ *
7
+ * Why this exists (#1811). Claude Code's native CLAUDE.md auto-loading
8
+ * is cwd-at-session-start + parents only. A switchroom agent's session
9
+ * starts at the agent's workspace, so when a user (often a non-coder
10
+ * over Telegram) asks the agent to "go work on ~/code/foo", the foo
11
+ * repo's CLAUDE.md is NOT in the agent's context — the agent has to
12
+ * remember to `Read` it manually. The agent's system prompt nudges
13
+ * this behaviour but it's a soft contract that depends on model
14
+ * obedience.
15
+ *
16
+ * This hook closes the loop. The first time the agent's Read / Edit /
17
+ * Write / MultiEdit / NotebookEdit / Bash touches a path under a repo
18
+ * that has a CLAUDE.md (etc), the hook reads it once and injects it.
19
+ * Subsequent tool calls in the same repo are no-ops (tracked per
20
+ * session in /tmp).
21
+ *
22
+ * Claude Code PreToolUse protocol (v1):
23
+ * Input: JSON on stdin — { session_id, transcript_path, cwd,
24
+ * tool_name, tool_input, ... }
25
+ * Output: exit 0 + empty stdout → allow, no change
26
+ * exit 0 + JSON {"hookSpecificOutput":
27
+ * {"hookEventName":"PreToolUse",
28
+ * "additionalContext":"<text>"}} → allow, inject text
29
+ *
30
+ * Kill switch:
31
+ * SWITCHROOM_DISABLE_REPO_CONTEXT_HOOK=1 disables entirely
32
+ *
33
+ * Tunables (env, optional):
34
+ * SWITCHROOM_REPO_CONTEXT_PER_FILE_MAX_BYTES default 30_000
35
+ * SWITCHROOM_REPO_CONTEXT_PER_SESSION_MAX_BYTES default 100_000
36
+ * SWITCHROOM_REPO_CONTEXT_PER_SESSION_MAX_FILES default 5
37
+ *
38
+ * Fail-open by design: any error (bad JSON, missing fields, fs error,
39
+ * unparseable path) → exit 0 with no output. The hook is a soft
40
+ * UX-improver; never break tool execution because of it.
41
+ */
42
+
43
+ import {
44
+ appendFileSync,
45
+ existsSync,
46
+ mkdirSync,
47
+ readFileSync,
48
+ statSync,
49
+ } from 'node:fs'
50
+ import { dirname, isAbsolute, join, normalize, resolve, sep } from 'node:path'
51
+ import { homedir, tmpdir } from 'node:os'
52
+ import { pathToFileURL } from 'node:url'
53
+
54
+ // ─── Tunables ─────────────────────────────────────────────────────────────
55
+
56
+ const MARKER_FILES = ['CLAUDE.md', 'AGENTS.md', 'AGENT.md']
57
+
58
+ function envInt(name, fallback) {
59
+ const raw = process.env[name]
60
+ if (raw == null || raw.length === 0) return fallback
61
+ const n = Number.parseInt(raw, 10)
62
+ return Number.isFinite(n) && n >= 0 ? n : fallback
63
+ }
64
+
65
+ const PER_FILE_MAX_BYTES = envInt(
66
+ 'SWITCHROOM_REPO_CONTEXT_PER_FILE_MAX_BYTES',
67
+ 30_000,
68
+ )
69
+ const PER_SESSION_MAX_BYTES = envInt(
70
+ 'SWITCHROOM_REPO_CONTEXT_PER_SESSION_MAX_BYTES',
71
+ 100_000,
72
+ )
73
+ const PER_SESSION_MAX_FILES = envInt(
74
+ 'SWITCHROOM_REPO_CONTEXT_PER_SESSION_MAX_FILES',
75
+ 5,
76
+ )
77
+
78
+ // Walk-up upper bound — never traverse past these directory roots
79
+ // when looking for a marker. Avoids picking up an operator's
80
+ // $HOME/CLAUDE.md on every random tool call.
81
+ const WALK_STOP_DIRS = new Set(['/', homedir()])
82
+
83
+ // ─── Helpers ──────────────────────────────────────────────────────────────
84
+
85
+ function readStdin() {
86
+ try {
87
+ return readFileSync(0, 'utf8')
88
+ } catch {
89
+ return ''
90
+ }
91
+ }
92
+
93
+ function isDisabled() {
94
+ const v = process.env.SWITCHROOM_DISABLE_REPO_CONTEXT_HOOK
95
+ return v === '1' || v === 'true' || v === 'yes'
96
+ }
97
+
98
+ /**
99
+ * Pick the target directory for the marker walk based on tool shape:
100
+ * - File tools (Read/Edit/Write/MultiEdit/NotebookEdit) — dirname of
101
+ * `tool_input.file_path`.
102
+ * - Bash — the hook envelope's `cwd` (which the Bash tool maintains
103
+ * across calls via its persistent shell, per Claude Code docs).
104
+ * - Anything else — null (skip).
105
+ *
106
+ * Returns an absolute, normalised path or null.
107
+ */
108
+ export function resolveTargetDir(toolName, toolInput, envelopeCwd) {
109
+ const fileTools = new Set([
110
+ 'Read',
111
+ 'Edit',
112
+ 'Write',
113
+ 'MultiEdit',
114
+ 'NotebookEdit',
115
+ ])
116
+ if (fileTools.has(toolName)) {
117
+ const raw = toolInput?.file_path ?? toolInput?.notebook_path
118
+ if (typeof raw !== 'string' || raw.length === 0) return null
119
+ const abs = isAbsolute(raw) ? raw : null
120
+ // Skip relative file paths — Claude Code overwhelmingly passes
121
+ // absolute paths; a relative one is ambiguous (relative to what?)
122
+ // and the hook envelope's `cwd` may not match the model's intent.
123
+ if (!abs) return null
124
+ return normalize(dirname(abs))
125
+ }
126
+ if (toolName === 'Bash') {
127
+ if (typeof envelopeCwd !== 'string' || envelopeCwd.length === 0) return null
128
+ if (!isAbsolute(envelopeCwd)) return null
129
+ return normalize(envelopeCwd)
130
+ }
131
+ return null
132
+ }
133
+
134
+ /**
135
+ * Walk up from `startDir` looking for the first marker file
136
+ * (CLAUDE.md, AGENTS.md, AGENT.md, in that order). Stops at filesystem
137
+ * root, the operator's $HOME, or after a generous depth cap.
138
+ *
139
+ * Returns the absolute path of the first marker found, or null.
140
+ */
141
+ export function findNearestMarker(startDir) {
142
+ if (typeof startDir !== 'string' || startDir.length === 0) return null
143
+ let dir = resolve(startDir)
144
+ const MAX_HOPS = 20
145
+ for (let i = 0; i < MAX_HOPS; i++) {
146
+ for (const name of MARKER_FILES) {
147
+ const candidate = join(dir, name)
148
+ try {
149
+ const st = statSync(candidate)
150
+ if (st.isFile()) return candidate
151
+ } catch {
152
+ // ENOENT and similar — try the next marker name / parent dir
153
+ }
154
+ }
155
+ if (WALK_STOP_DIRS.has(dir)) return null
156
+ const parent = dirname(dir)
157
+ if (parent === dir) return null
158
+ dir = parent
159
+ }
160
+ return null
161
+ }
162
+
163
+ /**
164
+ * Is `targetDir` inside the agent's own workspace? The workspace
165
+ * CLAUDE.md is auto-loaded by Claude Code at session start, so
166
+ * re-injecting it via additionalContext would be a redundant token
167
+ * cost. The workspace dir comes from the well-known
168
+ * `~/.switchroom/agents/<name>/workspace/` path (set up by the
169
+ * switchroom scaffold).
170
+ */
171
+ export function isUnderAgentWorkspace(targetDir, agentName, home) {
172
+ if (!agentName || typeof targetDir !== 'string') return false
173
+ const wsRoot = normalize(
174
+ join(home, '.switchroom', 'agents', agentName, 'workspace'),
175
+ )
176
+ const t = normalize(targetDir)
177
+ return t === wsRoot || t.startsWith(wsRoot + sep)
178
+ }
179
+
180
+ // ─── Session-scoped loaded-set tracker ────────────────────────────────────
181
+
182
+ /**
183
+ * Per-session state file — one path per line. Lazily created. Survives
184
+ * for the session and is mopped up by tmpfs eviction (or the next
185
+ * reboot inside the agent container, since /tmp is tmpfs there).
186
+ *
187
+ * Returns { stateDir, loadedPath, loaded: Set<string>, totalBytes }
188
+ */
189
+ function readSessionState(sessionId) {
190
+ // Sanitise session_id — Claude Code session ids are uuids but be
191
+ // defensive about path injection.
192
+ const safeId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)
193
+ const stateDir = join(tmpdir(), `switchroom-repo-context-${safeId}`)
194
+ const loadedPath = join(stateDir, 'loaded.txt')
195
+ const loaded = new Set()
196
+ let totalBytes = 0
197
+ try {
198
+ if (existsSync(loadedPath)) {
199
+ const lines = readFileSync(loadedPath, 'utf8').split('\n')
200
+ for (const line of lines) {
201
+ const trimmed = line.trim()
202
+ if (trimmed.length === 0) continue
203
+ // Each line: "<path>\t<bytes>"
204
+ const tabIdx = trimmed.indexOf('\t')
205
+ if (tabIdx < 0) {
206
+ loaded.add(trimmed)
207
+ continue
208
+ }
209
+ const path = trimmed.slice(0, tabIdx)
210
+ const sz = Number.parseInt(trimmed.slice(tabIdx + 1), 10)
211
+ loaded.add(path)
212
+ if (Number.isFinite(sz) && sz > 0) totalBytes += sz
213
+ }
214
+ }
215
+ } catch {
216
+ // best-effort
217
+ }
218
+ return { stateDir, loadedPath, loaded, totalBytes }
219
+ }
220
+
221
+ function recordLoaded(state, markerPath, bytes) {
222
+ try {
223
+ if (!existsSync(state.stateDir)) {
224
+ mkdirSync(state.stateDir, { recursive: true, mode: 0o700 })
225
+ }
226
+ appendFileSync(state.loadedPath, `${markerPath}\t${bytes}\n`, {
227
+ mode: 0o600,
228
+ })
229
+ } catch {
230
+ // If we can't persist, the worst case is we re-inject on the
231
+ // next tool call. Acceptable.
232
+ }
233
+ }
234
+
235
+ // ─── Main ─────────────────────────────────────────────────────────────────
236
+
237
+ function buildContext(markerPath, body, truncated) {
238
+ const truncNote = truncated
239
+ ? '\n\n[…repo CLAUDE.md was truncated to fit the per-file injection budget — `Read` it directly for the full contents.]'
240
+ : ''
241
+ return (
242
+ '<repo-context source="switchroom repo-context hook">\n' +
243
+ `The agent's tool call is touching a path under \`${dirname(markerPath)}\` ` +
244
+ `which has guidance at \`${markerPath}\`. ` +
245
+ `That guidance is reproduced here so it's part of this turn's context ` +
246
+ `(Claude Code's native CLAUDE.md auto-load only fires for the session's ` +
247
+ `start cwd and parents, not for repos the agent navigates into ` +
248
+ `mid-session). Follow this repo's conventions for any work in it.\n\n` +
249
+ '---\n' +
250
+ body +
251
+ truncNote +
252
+ '\n---\n' +
253
+ '</repo-context>\n'
254
+ )
255
+ }
256
+
257
+ function buildPointer(markerPath) {
258
+ return (
259
+ '<repo-context source="switchroom repo-context hook">\n' +
260
+ `The agent's tool call is touching a path under \`${dirname(markerPath)}\`. ` +
261
+ `That repo has guidance at \`${markerPath}\` which is larger than the ` +
262
+ `per-file injection budget — \`Read\` it directly before any ` +
263
+ `substantive work in this repo.\n` +
264
+ '</repo-context>\n'
265
+ )
266
+ }
267
+
268
+ async function main() {
269
+ if (isDisabled()) process.exit(0)
270
+
271
+ const raw = readStdin().trim()
272
+ if (raw.length === 0) process.exit(0)
273
+
274
+ let event
275
+ try {
276
+ event = JSON.parse(raw)
277
+ } catch {
278
+ process.exit(0)
279
+ }
280
+
281
+ const toolName = event?.tool_name
282
+ const toolInput = event?.tool_input
283
+ const sessionId = event?.session_id
284
+ const envelopeCwd = event?.cwd
285
+ if (typeof toolName !== 'string' || !sessionId) process.exit(0)
286
+
287
+ const targetDir = resolveTargetDir(toolName, toolInput, envelopeCwd)
288
+ if (targetDir == null) process.exit(0)
289
+
290
+ // Skip the agent's own workspace — Claude Code already loaded its
291
+ // CLAUDE.md at session start. Re-injecting would be a token-cost
292
+ // duplicate.
293
+ const agentName = process.env.SWITCHROOM_AGENT_NAME ?? ''
294
+ const home = homedir()
295
+ if (isUnderAgentWorkspace(targetDir, agentName, home)) process.exit(0)
296
+
297
+ const markerPath = findNearestMarker(targetDir)
298
+ if (markerPath == null) process.exit(0)
299
+
300
+ const state = readSessionState(sessionId)
301
+
302
+ // Already-loaded dedup — the load-once-per-repo-per-session invariant.
303
+ if (state.loaded.has(markerPath)) process.exit(0)
304
+
305
+ // Per-session caps — degrade gracefully when the agent wanders.
306
+ if (state.loaded.size >= PER_SESSION_MAX_FILES) process.exit(0)
307
+ if (state.totalBytes >= PER_SESSION_MAX_BYTES) process.exit(0)
308
+
309
+ // Read the marker. Skip if oversized — emit a pointer instead so the
310
+ // agent at least knows the file exists.
311
+ let body = ''
312
+ let truncated = false
313
+ try {
314
+ body = readFileSync(markerPath, 'utf8')
315
+ } catch {
316
+ process.exit(0)
317
+ }
318
+
319
+ let outputContext
320
+ if (body.length > PER_FILE_MAX_BYTES) {
321
+ outputContext = buildPointer(markerPath)
322
+ // Record so we don't pointer-spam either.
323
+ recordLoaded(state, markerPath, outputContext.length)
324
+ } else {
325
+ // Trim trailing whitespace so the wrapped envelope reads cleanly.
326
+ body = body.replace(/\s+$/, '')
327
+ outputContext = buildContext(markerPath, body, truncated)
328
+ recordLoaded(state, markerPath, outputContext.length)
329
+ }
330
+
331
+ process.stdout.write(
332
+ JSON.stringify({
333
+ hookSpecificOutput: {
334
+ hookEventName: 'PreToolUse',
335
+ additionalContext: outputContext,
336
+ },
337
+ }),
338
+ )
339
+ process.exit(0)
340
+ }
341
+
342
+ // Gate the entrypoint to direct-invocation only. When this module is
343
+ // imported (by the vitest/bun test that exercises the pure helpers),
344
+ // running main() would block forever on stdin — the test harness has
345
+ // no stdin to feed it, and readFileSync(0) hangs.
346
+ const invokedDirectly =
347
+ typeof process !== 'undefined'
348
+ && Array.isArray(process.argv)
349
+ && process.argv[1]
350
+ && import.meta.url === pathToFileURL(process.argv[1]).href
351
+ if (invokedDirectly) {
352
+ main().catch(() => process.exit(0))
353
+ }