Skip to content

Hook & Slash Command Settings

dazeb edited this page Sep 17, 2026 · 2 revisions

Hook & Slash Command Settings

Two settings-backed lists look similar in the UI but come from very different places, and the two core modules behind them reflect that split. Agent hooks are read off the filesystem from CLI configuration files, so their module is defensive about I/O, parse failures and secrets. Slash commands are defined in-process, so their module is mostly a table plus a consistency gate against the chat parser. Neither module renders anything, speaks IPC, or imports the other; both are data producers that hand back SettingsHook[] or SettingsCommand[] to whatever layer serves settings (Electron main, or the server-edition RPC side).

This page follows the mechanism first, then what each file owns, the invariants to preserve, and the seams where changes are expected.

Runtime mechanism

Hooks: caller-supplied files into SettingsHook[]

inventoryHooks(files) in src/core/settings-hooks.ts is the entire read path. It never discovers files itself: the caller supplies a HookFile[], each entry pairing a filesystem path with the agent id that file belongs to and an optional source ('managed' | 'legacy'). Keeping the file list caller-supplied is the important separation — which CLI keeps hooks in which file is knowledge owned by the installers that write those files (see the Hook Server & CLI Hook Installers page), while this module owns only the text-to-record conversion.

flowchart TD
    A["Caller builds HookFile[]<br/>path + agent + optional source"] --> B["inventoryHooks(files)"]
    B --> C["readFileSync(path, 'utf8') per entry"]
    C -->|throws| X["file contributes nothing"]
    C --> D["parseHookText(raw, agent, source)"]
    D -.-> S["classification: explicit source wins,<br/>else raw contains __termsprawl → 'managed'"]
    D --> E{"JSON.parse succeeds?"}
    E -->|yes| F["top-level event keys, entries[].hooks[];<br/>command = hook.url ?? hook.command"]
    F --> G{"at least one hook?"}
    G -->|yes| H["parsed hooks"]
    G -->|no| I["line fallback:<br/>event keyword line + command=/url= quoted value"]
    E -->|no| I
    I --> H
    H --> J["flatten across files, sort by id"]
    J --> K["SettingsHook[] for the settings list"]
Loading

Key nodes in that flow:

  • Read error (throws) — inventoryHooks wraps parseHookText(readFileSync(path, 'utf8'), …) per file in try/catch and contributes [] for that file. One unreadable file never removes hooks from other files, and a malformed config never throws into the caller. The trade-off is silence; see Boundary conditions.
  • Classification — the caller-provided source wins. Otherwise the raw text is tested with /__termsprawl(?:Managed)?/: a match marks the file 'managed', meaning it was written by this app's hook installers; anything else is 'legacy'. This is a coarse, whole-file, substring-level check.
  • JSON branch — the raw text is parsed as a JSON object whose top-level keys are hook event names. For each key whose value is an array, each entry's hooks[] array is walked, and a hook is taken from hook.url ?? hook.command (URL wins when both exist), guarded by a type check on the extracted value. Non-array values and entries without a hooks array are ignored.
  • Short-circuit — if the JSON branch produced at least one hook, it returns immediately and the line fallback never runs. Only a parse failure or a successful parse that yields zero hooks falls through.
  • Line fallback — for legacy, line-oriented configs. Each line is scanned for an event keyword; a match sets the current event and it stays current for following lines. A line matching (?:command|url)\s*=\s*["']([^"']+) contributes a hook for the current event. Values must be quoted; unquoted assignments are skipped.
  • Sorting — inventoryHooks flattens all per-file results and sorts by id (a.id.localeCompare(b.id)), so the settings list has stable ordering regardless of file order or read order.

The event keywords the fallback recognizes are baked into one alternation: PreToolUse, PostToolUse, Notification, Stop, UserPromptSubmit, PermissionRequest, SubagentStop, SessionStart, SessionEnd, SubagentStart, PreCompact, PostCompact. An event name outside that list is invisible to the fallback parser; the JSON branch does not depend on this list because it uses the object keys.

Secrets in hook strings

Hook commands and URLs are prime carriers for API keys and tokens, and these records end up in a settings list, so the module exports redactHookSecret(s). It applies two patterns:

  1. Query-string parameters named key, token, secret, api_key or password (case-insensitive): the value after = is replaced with [redacted], the parameter name is preserved.
  2. Standalone tokens with an sk_ or ghp_ prefix (case-sensitive, word-bounded): replaced with [redacted].

Both patterns are name/prefix-specific. A token shape they do not cover — a different prefix, a bare secret passed as a positional argument — passes through unchanged, so "redacted" must not be read as "contains no secrets". The excerpt shows the function being exported rather than its call sites, so any new display path for hook strings should call it deliberately.

Slash commands: a static table gated on parser parity

src/core/settings-commands.ts is much smaller because nothing is read from disk. BUILTIN_COMMANDS is a module-level SettingsCommand[] describing the commands the app itself implements. Everything visible in the excerpt has source: "built-in" and available: true:

Command Description
/clear Clear the conversation
/model Change the chat model
/system Set the system prompt
/cost (description truncated in the excerpt)

The array continues past what the excerpt shows; the table is only what is visible.

Two functions wrap that constant:

  • discoverCommands() returns BUILTIN_COMMANDS.map(x => ({...x})) — a fresh array of copies. Callers can sort, filter or annotate the result without mutating the module constant, so command discovery is per-call state, not shared state. At this level only built-ins are discoverable; SettingsCommand.source and .available leave room for other origins, but nothing in this module produces them today.
  • commandsHaveParserParity() checks that every builtin name resolves to something truthy at detectSlashCommand(name)?.command, where detectSlashCommand comes from src/core/chat/conversation.ts. This is the cross-module contract: the list that advertises commands and the parser that recognizes them cannot drift. Adding a row to BUILTIN_COMMANDS without teaching the chat parser about it makes this check fail.

The module also re-exports detectSlashCommand, so a settings or input consumer can classify typed text through the same parser that dispatch uses instead of growing a second matcher. No call site for commandsHaveParserParity() is visible in the file; it reads as a regression guard for tests or a startup assertion, and should stay in that role if the command surface grows.

How the two lists reach the settings UI

The call chain that matters for changes is thin, and its ends are outside these two files:

  1. The settings data path (main process for the Electron build, RPC handlers for the server edition) asks for the hooks list and the commands list.
  2. Hooks path: the caller assembles HookFile[] from the CLI config locations it knows about, calls inventoryHooks, and forwards the SettingsHook[] — running string fields through redactHookSecret before anything is displayed.
  3. Commands path: the caller calls discoverCommands() for the list, and uses the re-exported detectSlashCommand wherever typed input must be classified.
  4. commandsHaveParserParity() is orthogonal to rendering and belongs wherever a regression should fail loudly.

Because hook inventory re-reads the filesystem on every call and caches nothing, a settings reload naturally reflects edits to a CLI config file. Because command discovery copies a constant, its content changes only when code changes.

File responsibilities

src/core/settings-hooks.ts

The full public surface, all stateless:

  • HookFile — input descriptor: path (filesystem path to a candidate hook config), agent (attribution label threaded through parsing), optional source to override classification.
  • redactHookSecret(s) — the secret-sanitizing helper for this area; coverage described above.
  • parseHookText(raw, agent, source?) — pure text-to-records conversion using the JSON-then-lines strategy. It does not throw: JSON errors are caught internally, and the line fallback always runs when the JSON branch yields nothing.
  • inventoryHooks(files) — the I/O layer: reads each file, isolates failures per file, flattens and sorts by id.

There is no cache and no module-level mutable data, which is why the same function can back both a first render and a later refresh, from Electron main or from server-edition handlers. It is not renderer-safe: it imports node:fs directly, so renderer code must reach hooks through the IPC/RPC bridge rather than importing this module.

src/core/settings-commands.ts

  • BUILTIN_COMMANDS — the single source of truth for what the settings list shows, with name, description, source and available on every entry.
  • discoverCommands() — clone-based accessor; the seam where additional command sources would be merged, since SettingsCommand already models an origin field that this module only ever sets to 'built-in'.
  • commandsHaveParserParity() — the contract check against the chat parser.
  • The detectSlashCommand re-export — a forwarding alias for src/core/chat/conversation.ts, so consumers need one import for both listing and recognition.

Collaborators

  • src/shared/types.ts (the module's ../shared/types import) supplies SettingsHook and SettingsCommand; both core modules are typed against it, and field changes ripple into the settings UI.
  • src/core/chat/conversation.ts (the ./chat/conversation import) supplies detectSlashCommand, the parser authority that the parity check validates against.
  • The hook writers (Hook Server & CLI Hook Installers) own the paths and file contents that inventoryHooks later reads; the two sides share a file-format contract but no code.

Key state and invariants

  • No cache, no shared mutable state. inventoryHooks reflects disk at call time; discoverCommands returns copies; BUILTIN_COMMANDS is effectively read-only.
  • Per-file failure isolation. A missing, unreadable or malformed hook file contributes an empty list, never an exception.
  • Stable ordering. Hooks are sorted by id; treat SettingsHook.id as the identity used for list keys and dedupe.
  • JSON-first parsing. A file that parses as JSON and yields at least one hook is never consulted as line-oriented text.
  • Parser parity. Every builtin command name must be recognized by detectSlashCommand; that is the invariant commandsHaveParserParity encodes.

Boundary conditions and gotchas

  • Silent hook failures. inventoryHooks swallows read and parse errors. A corrupted managed hook file simply makes the settings list shorter, with no diagnostic channel. If "file X could not be read" must be surfaced, the try/catch in inventoryHooks is the place to change the return shape.
  • Whole-file classification. The __termsprawl marker check runs over the entire raw text, not per command, so a file mixing app-managed and hand-written hooks is labeled by whichever marker appears anywhere in it.
  • URL priority. hook.url ?? hook.command means a hook object carrying both fields silently drops the command.
  • JSON shape strictness. Only top-level array values and each entry's hooks array are walked. Other plausible shapes fall through to the line parser, which may still recover event/command text but loses structure.
  • Quoting in the fallback. command=/url= values must be quoted; a command line with no preceding event keyword is dropped because the current event is still undefined. The event is sticky across lines, which is correct for grouped formats and wrong for free-form files that interleave.
  • Redaction coverage. Only the named query parameters and sk_/ghp_ token shapes are replaced; the token pattern is case-sensitive and prefix-specific.
  • Parity covers builtins only. If discoverCommands() is extended beyond BUILTIN_COMMANDS, commandsHaveParserParity() will not check the new entries until it is rewritten to iterate the discovered set.
  • Renderer import hazard. settings-hooks.ts pulls in node:fs; importing it from renderer code breaks the bundle boundary.

Extension points

  • New agent CLI: add its config file as a HookFile with path and agent; pass source: 'managed' when provenance is known, skipping the marker sniff.
  • New hook event in legacy files: extend the event alternation inside parseHookText's fallback; JSON-written configs pick new keys up automatically.
  • New builtin command: add an entry to BUILTIN_COMMANDS and make sure detectSlashCommand recognizes the name, then let the parity check confirm it.
  • New command source (project- or user-scoped): extend discoverCommands() to merge additional entries; SettingsCommand.source/available already model origin and enablement.
  • Surfacing parse errors: change inventoryHooks to return per-file diagnostics (or add a sibling function) instead of the current empty-list fallback.
  • New secret shapes: extend the regexes in redactHookSecret and apply redaction consistently at every display boundary, since it is not automatic.

Limitations

  • The excerpt truncates the long lines inside parseHookText (lines 8 and 10) and the BUILTIN_COMMANDS literal beyond /cost. The exact field mapping into SettingsHook and the full command inventory cannot be verified from this page; read the files directly.
  • SettingsHook and SettingsCommand are imported from src/shared/types.ts, which is not included in the excerpt.
  • detectSlashCommand's matching rules live in src/core/chat/conversation.ts; only the fact that a recognized name yields a truthy .command is evidenced here.
  • The consumer side — IPC/RPC channel names, list components, and whether either inventory is also persisted in the app settings store — is outside these two files; consult the Settings Panel & Capability Pages and the Preload Bridge & IPC Contract pages.

Sources: src/core/settings-hooks.ts, src/core/settings-commands.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally