Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
This commit is contained in:
@@ -13,6 +13,7 @@ const config: Config = {
|
||||
pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
|
||||
projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
|
||||
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default)
|
||||
stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -73,6 +74,8 @@ export interface Config {
|
||||
projectDir?: string
|
||||
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
|
||||
defaultTimeoutMs?: number
|
||||
/** Character cap for the `hook/result` event's persisted stderr summary. */
|
||||
stderrSummaryMaxChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -80,6 +83,7 @@ export const Config: z<Config> = z.object({
|
||||
pluginRoot: z.string(),
|
||||
projectDir: z.string(),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
})
|
||||
|
||||
/** A stable per-handler id so an invoked/result pair correlates in the log. */
|
||||
@@ -91,13 +95,6 @@ function nextHandlerId(point: string): string {
|
||||
/** The `{kind:'plugin'}` source stamped on every context this bridge injects. */
|
||||
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' }
|
||||
|
||||
/** Truncate a stderr blob for the `hook/result` summary field. */
|
||||
function summarize(stderr: string): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > 500 ? t.slice(0, 500) + '…' : t
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// --- Parse the config ONCE at load. A read/parse failure is contained: the
|
||||
// bridge logs and registers nothing rather than crashing boot (a typo'd path
|
||||
@@ -119,6 +116,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
|
||||
/**
|
||||
* Run every command hook configured for `point` whose matcher selects
|
||||
@@ -182,7 +180,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarize(output.stderr)
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
|
||||
@@ -27,7 +27,7 @@ function hooks(d: string, h: unknown): string {
|
||||
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
|
||||
}
|
||||
|
||||
type HarnessOpts = { pluginRoot?: string; projectDir?: string }
|
||||
type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number }
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -139,6 +139,21 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', ()
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user