switchroom 0.13.46 → 0.13.48
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.
- package/dist/cli/switchroom.js +221 -227
- package/dist/host-control/main.js +16 -9
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +5 -5
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/repo-context-pretool.mjs +353 -0
- package/telegram-plugin/tests/repo-context-pretool.test.ts +472 -0
|
@@ -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
|
+
}
|