Skip to content
| Marketplace
Sign in
Visual Studio Code>Other>chirinNew to Visual Studio Code? Get it now.
chirin

chirin

Kei-Ikeda

|
1 install
| (0) | Free
A zero-dependency VS Code extension that watches files for changes and delivers them to the macOS Notification Center. Built first of all for Claude Code hooks inside an egress-blocked devcontainer, but any file can be watched. (macOS only)
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

chirin 🎐

Word on the wind. A zero-dependency VS Code extension that carries file changes to the macOS Notification Center — first of all, Claude Code hooks that fire inside a devcontainer with no network egress.

Marketplace CI License: MIT Dependencies: 0 Platform: macOS

風の便り — kaze no tayori: news that reaches you on the wind, from somewhere you cannot see.

macOS only. Notifications go through osascript, so on Windows and Linux the extension activates but no notification appears.

Quick start

Everything happens on the host, in the Command Palette (Shift+Cmd+P). Nothing to install inside the container.

  1. Install it

    code --install-extension kei-ikeda.chirin
    

    Or search chirin in the Extensions view (Shift+Cmd+X) and pick the one published by kei-ikeda.

  2. Create the config — run chirin: Create config file

    It writes ~/.config/chirin/config.json and opens it. Two working rules for Claude Code are already in there.

  3. Point it at your repositories — edit watch in the editor that just opened, then save

    The default assumes they live at ~/src/<host>/<owner>/<repo>:

    "watch": ["~/src/*/*/*/.claude/chirin-notify-state.json"]
    

    Each * is exactly one directory level, so add or remove one to match your own layout. A save is picked up within a few seconds. chirin: Validate config prints the files that actually matched, so a wrong number of levels shows up immediately.

  4. Install the hook — open the repository you want notifications for and run chirin: Install the Claude Code hook into this project

    Identical whether that folder is open locally or in a devcontainer. Repeat once per repository.

  5. Ring it once — run chirin: Send test notification, and allow notifications when macOS asks

That is the whole setup. $(bell) chirin at the right end of the status bar means it is watching; everything below is reference.

Running Claude Code in iTerm2 on the host? Its built-in notifications are the better choice there — they are attributed to iTerm2, and clicking one takes you back to the terminal. chirin is for the places those never reach: Claude Code inside a devcontainer, and log or data files that have nothing to do with Claude Code. To run both, have the hook check TERM_PROGRAM.

What it is

chirin watches a handful of files on the host. When one of them changes, a rule decides whether that is worth a notification and how it should read, and macOS rings. Its main use is getting Claude Code hooks (Stop, Notification and friends) that fire inside an egress-blocked devcontainer through to the host — but nothing in it is tied to Claude Code. It also follows an application's log file and reports updates to any file (see Source types).

  • Zero dependencies. dependencies is empty, and CI fails if it is not. The only runtime is the VS Code extension host
  • No network. The only delivery path is a file in the already bind-mounted workspace. No egress policy needs loosening
  • Auditable. The whole extension is a few thousand lines of TypeScript. No bundler is used, so unpacking the .vsix shows exactly the code that runs
  • A bell, not a hand. chirin rings; it never runs anything. "Run a command on match" is a permanent non-goal, because it would turn untrusted container-side input into code execution on the host

How it works

[devcontainer]
  Claude Code hook (Stop / Notification)
        │ writes to tmp → renames onto chirin-notify-state.json (atomic swap)
        ▼
  <workspace>/.claude/chirin-notify-state.json
        │ bind mount (an existing path; no network)
━━━━━━━━┿━━━━━━━━━━ container boundary
        ▼
[macOS]
  chirin extension (VS Code's UI extension host)
        │ readFile every second → compare the ts field with the previous value
        │ changed → rule match → throttle → sanitize
        ▼
  /usr/bin/osascript (fixed script + argv)
        ▼
  Notification Center

This is a UI extension ("extensionKind": ["ui"]), so even with a devcontainer open the extension host lives on the host macOS side and can call osascript and read the host's real paths directly. The container is treated as untrusted: everything in the state file is validated and sanitized before it reaches a notification. Every defense that follows from that premise is listed in the threat model.

Setup in detail

Requirements

  • macOS (Apple Silicon / Intel)
  • VS Code 1.101 or later. No separate Node.js installation is needed (it runs on the extension host's Node)
  • To use the hook inside a container, that container needs node and bash

Installing from a .vsix

Download chirin-<version>.vsix from the Releases page, check its SHA-256 against the value in the release notes, then install it:

shasum -a 256 chirin-<version>.vsix
code --install-extension chirin-<version>.vsix

You can also build one yourself with npm ci && npm run package.

The setup walkthrough

A "Set up chirin" walkthrough opens right after installation and runs the same commands as the Quick start above, one per step. If it does not open on its own, run chirin: Open setup.

The status bar

Clicking $(bell) chirin takes you to the "next thing to do" for the current state; hovering shows the details. A state name appears only when you have something to do — a bare chirin means everything is fine.

Display Meaning Click target
chirin Watching is running (healthy). The hover says whether it is this window or another one Show log
chirin: Electing Leader election has not settled yet (normally becomes chirin in an instant) Show log
chirin: Unknown A permission or I/O error keeps failing the lock operations, so whether any window is watching cannot be determined. Notifications may have stopped. A merely stale lock is not this state: that one is taken over automatically Show log
chirin: Disabled chirin.enabled is false Resume watching
chirin: Not configured No config yet (the normal state on a first run) Open setup
chirin: Config error The config could not be read Open config file

Only one window watches at a time (see Multiple windows), but notifications appear in the macOS Notification Center, so it makes no difference which window is watching.

Installing the hook into a repository

chirin: Install the Claude Code hook into this project rewrites three files inside the workspace (.gitignore included):

  1. Places .claude/hooks/chirin-notify.sh
  2. Merges into the hooks section of .claude/settings.local.json. An existing chirin entry is replaced rather than duplicated, so re-running picks up a changed hook invocation without registering it twice (the file is re-serialized with two-space indentation)
  3. Appends a # chirin block to the end of .gitignore, untracking the three files chirin writes into the workspace: .claude/hooks/chirin-notify.sh, .claude/chirin-notify-state.json and .claude/.chirin-notify-tmp-*

Step 3 only appends; it never rewrites an existing line, and does nothing when the same line is already present. In a repository without a .gitignore, one is created. Note that chirin does not add .claude/settings.local.json itself: that file belongs to Claude Code, and whether to track it is the repository's decision.

It writes to the personal, local .claude/settings.local.json rather than the shared .claude/settings.json because this hook only means anything in the installer's own environment: a macOS host, with chirin installed, whose config watches this repository. Writing to the shared file would put a personal difference into a committed file and ship a hook that does not work to teammates on other operating systems. The hook script is ignored for the same reason, and because it changes between releases — tracking it would put a diff in the repository every time someone picks an update up. If your team does want it committed, delete that line from the # chirin block; .gitignore has no effect on an already tracked file.

To do it by hand, copy hooks/chirin-notify.sh from inside the .vsix to <workspace>/.claude/hooks/chirin-notify.sh and merge the content of hooks/settings.example.json into <workspace>/.claude/settings.local.json. The hook is invoked as bash <path>, so it needs no executable bit.

Upgrading from 0.8.0 or earlier? Every file chirin handles was renamed to start with chirin- in 0.9.0 (notify.sh → chirin-notify.sh, notify-state.json → chirin-notify-state.json, .notify-tmp-* → .chirin-notify-tmp-*), and the old ones are not cleaned up automatically — re-running the install command alone leaves the old hook registration in place and the same event notifies twice. After re-running it, delete by hand: the old hook entry calling notify.sh from .claude/settings.local.json, the three old files themselves, and the two stale lines left in the # chirin block of .gitignore. Then change notify-state.json to chirin-notify-state.json in the host-side config's watch patterns, and confirm with chirin: Validate config that the targets appear under targets now.

Multiple windows

A VS Code extension host is one process per window. Left alone, three open windows would notify three times for the same event, so chirin's windows contend for a lock file in the same directory as the config (watcher-<digest>.lock, mode 0600) and only the window that becomes leader watches. The digest is taken from the config file's resolved path, so two configs living side by side get a lock each, while equivalent paths to the same config (through a symlinked directory, or with different casing on a case-insensitive volume) resolve to one.

  • Closing the leader's window releases the lock immediately and another window takes over
  • If the leader is force-quit, the timestamp inside its lock stops being refreshed and goes stale, another window steals the lock, and the handover completes within roughly 10 seconds regardless of how pollIntervalMs is set. A lock left behind by a crash is never a deadlock
  • chirin.configPath is scope: application, so it is shared by every window in the profile: a single lock, and exactly one watching window among those opened in the same profile
  • The lock was named watcher.lock up to 1.0.2. After upgrading from those versions, restart every window — an older build contends for the old name and would watch in parallel. A leftover watcher.lock is inert

Commands

Open the Command Palette (Shift+Cmd+P), type chirin and pick one.

Command What it does
chirin: Create config file Creates ~/.config/chirin/config.json from the template (mode 0600). An existing file is overwritten only after confirmation
chirin: Open config file Opens the config in the editor
chirin: Validate config Validates the config and prints the expanded rules and watch targets to the output panel
chirin: Send test notification Fires one notification from a dummy event (for the first permission prompt). Picking a rule uses that rule's template and sound settings. The default uses the Pop sound, so the audio path is exercised too
chirin: Reload config Re-reads the config and rebuilds the watch (for when you want a save applied immediately rather than waiting)
chirin: Start / stop watching Toggles chirin.enabled
chirin: Show log Opens the chirin channel in the output panel
chirin: Install the Claude Code hook into this project Installs the hook into the open workspace
chirin: Open setup Opens the first-run setup walkthrough

Errors are surfaced through three paths: a notification popup, the output panel and the status bar. The extension activates even with a broken config, and the commands always work (so the path to fixing the config is never lost).

Automatic config reload

When you save the config, every window notices the change within a few seconds and rebuilds its watch. Editing in any window applies, so you never have to care which window is notifying.

If the saved content is broken (a mid-edit save, say), chirin keeps watching with the last valid config and raises a warning in the status bar and the output panel. This keeps notifications from stopping silently; fixing and saving again recovers automatically. No popup is shown in that case (one on every save would get in the way of editing). Running chirin: Reload config explicitly still reports errors through all three paths.

Extension settings

All of them are scope: application (user settings only). That is deliberate, so a .vscode/settings.json the container can write cannot override them — do not change it.

Setting Default Description
chirin.configPath "" Path to config.json. When empty, ~/.config/chirin/config.json
chirin.enabled true Enable file watching
chirin.showInEditorToast false Also show a notification inside the VS Code window, in addition to the OS notification

Config reference

~/.config/chirin/config.json (host side, outside the workspace). It is refused at startup if the file or its directory is group/other writable, or if the file is a symlink. On POSIX, both the file and its parent directory must be owned by the current user or root; uid checks are skipped where process.getuid is unavailable. Root-owned files are accepted for MDM provisioning, but the user running VS Code still needs permission to create the watcher lock beside the config (for example, a root-owned 0644 file in the user's own 0700 directory). Config reads, including auto-reload checks, are capped at 1MB and reject special files.

The extension is .json, but it is read as JSON with comments (JSONC). // and /* */ are allowed, so unused settings can be left commented out (trailing commas are not supported). Opening it through chirin: Open config file displays it as jsonc.

The shape:

{
  "defaults": {
    "pollIntervalMs": 1000,
    "globRefreshMs": 30000,
    "throttleMs": 5000
  },
  "rules": [
    {
      "id": "claude-stop",
      "watch": ["~/src/*/*/*/.claude/chirin-notify-state.json"],
      "match": { "type": "regex", "field": "background_task_count", "pattern": "^0$" },
      "notify": {
        "title": "Claude Code",
        "subtitle": "Complete 🚀",
        "message": "{{dir}}",
        "sound": "Glass"
      },
      "throttleMs": 3000
    }
  ]
}

chirin: Create config file writes this out with two working Claude Code rules and a comment on every field, so the generated file is the authoritative example — the tables below only say what each field accepts.

Notifications scan best as three tiers: title (who) / subtitle (what happened) / message (details). Varying the sound by event type (Glass for completion, Ping for a pending confirmation, say) lets you tell them apart without looking at the screen.

Field Description
defaults.pollIntervalMs File read interval (>= 200, default 1000)
defaults.globRefreshMs Glob re-expansion interval, i.e. new project detection (>= 5000, default 30000)
defaults.throttleMs Default notification suppression window for a rule (>= 0, default 5000)
rules[].id /^[a-z0-9][a-z0-9-]{0,63}$/, unique
rules[].watch Watch patterns. ~/ expands to home. The only wildcard is a whole path segment * (** and foo* are not allowed). The last segment is a file name. Literal directories in a level an untrusted party such as a container can write are limited to one (see Known limitations)
rules[].source Watch method. Defaults to { "type": "json-state" }. See Source types
rules[].match.type any / event / equals / contains / regex. See the table below
rules[].notify.title Template. Truncated to 60 characters after expansion. Defaults to "chirin"
rules[].notify.subtitle Template. The notification's second line. Truncated to 60 characters after expansion. Optional
rules[].notify.message Template. Required. Truncated to 120 characters after expansion
rules[].notify.sound /^[A-Za-z ]{1,30}$/ (e.g. Pop). No sound when omitted. The name is looked up in the macOS sound directories, so a sound of your own works too: put chirin.aiff in ~/Library/Sounds/ and write "chirin" (the file name without its extension)
rules[].throttleMs Notification suppression window per rule x file. Defaults to defaults.throttleMs

Match types

type Fields Meaning
any — Always matches. For notifying that something changed at all
event equals Exact match against the event field (a shorthand for json-state)
equals field, value Exact match against an arbitrary field
contains field, pattern Substring (pattern <= 200 characters)
regex field, pattern Regular expression (pattern <= 256 characters)

The default when field is omitted varies by source type (json-state→message, log-lines→line, file-meta→size). The match target is truncated to its first 200 characters at runtime (a ReDoS defense).

regex matching never runs on the extension-host thread: it is handed to a worker thread, and a rule whose match does not come back within 1 second has that worker terminated and the rule disabled for the rest of the session with a warning. The window, the poll loop and the leader heartbeat keep running throughout. A rule disabled this way stays disabled until the config is reloaded. Notifications from a regex rule arrive within the same poll cycle rather than synchronously with it. Why it is built this way rather than checked for dangerous patterns is in the threat model.

Source types

type Change detection Read limit On overflow Exposed fields
json-state A change in the ts field 64KB Reject (anomalous for a state file) ts / event / notification_type / background_task_count / message / cwd plus arbitrary fields from the JSON
log-lines Appended lines (already-notified lines excluded by hash) windowBytes (default 1MB, 4KB–16MB) and 2,000 complete lines per file per poll Process only the newest bounded batch (warning states what was skipped) line
file-meta mtime + size Content is never read — size / mtime

log-lines is best-effort by design: older complete lines past either limit are skipped with a warn log naming the bytes, while the final partial line waits for completion. Blank and duplicate lines count toward the line cap, and raising windowBytes allows longer lines but does not raise it. A line identical by hash to one in the most recently read batch is not notified again, so a message repeated on every poll does not ring every poll; identical lines within one batch collapse into a single notification carrying {{count}}. Only content appended after watching begins is notified, and when the file shrinks, reading restarts from the beginning (a rotation whose new file is already larger than the old one cannot be detected).

Why the default Claude Code rules match the way they do

Both default rules match on a field rather than on the event, and it is worth knowing why before you loosen them.

Stop fires at the end of every turn — including a turn that ended only because the session parked to wait for background work. So the completion rule matches background_task_count on ^0$ and rings only on a real completion. To notify on every turn instead, use { "type": "event", "equals": "Stop" }.

Claude Code raises Notification for background agents, teammates and cloud sessions too, and the message text cannot tell them apart from your own session. The hook writes Claude Code's notification_type so a rule can, and the default rings only for permission_prompt — a question you have to answer. It deliberately leaves out idle_prompt, which fires whenever the session goes idle including while background work is still running. The generated config lists the other values in a comment next to that rule.

Both fields are absent for events they do not apply to, so neither rule can fire for the other event.

Installed the hook before these fields existed? Re-run chirin: Install the Claude Code hook into this project to pick them up — an older chirin-notify.sh writes neither, so a rule matching on them stays permanently silent.

Watching anything else

A log-lines example (follow an application log and notify only on error lines):

{
  "id": "app-errors",
  "watch": ["~/src/*/app.log"],
  "source": { "type": "log-lines", "windowBytes": 1048576 },
  "match": { "type": "contains", "pattern": "ERROR" },
  "notify": { "title": "App error", "message": "{{dir}}: {{line}} ({{count}})" }
}

A file-meta example (report an update regardless of content):

{
  "id": "export-done",
  "watch": ["~/src/*/export.csv"],
  "source": { "type": "file-meta" },
  "match": { "type": "any" },
  "notify": { "message": "{{file}} updated ({{size}} bytes)" }
}

Template placeholders

Name Content Trust
{{dir}} Directory name derived from the watched file path (the * segment above it, or one higher when that is .claude) path-derived, but a container can pick the name when that * level lies inside a workspace, so it is treated as untrusted → sanitized like the source fields
{{file}} Basename of the watched file trusted
{{count}} Number of matches for that rule within the same cycle trusted
A source's exposed fields line / message / size and the rest from the table above untrusted → sanitized

Unknown placeholders are left as-is. When the same rule matches several times within one cycle, they are collapsed into a single notification whose body carries the latest event and {{count}}.

Known limitations and pitfalls

  • Closing every VS Code window stops notifications: the extension only runs inside a VS Code window. There is no mechanism to stay resident from login. One open window is enough
  • Startup follows VS Code's: activation is on onStartupFinished, so nothing is watched for the first few seconds after opening a window
  • No notifications outside macOS: installing on Windows or Linux does not crash the extension, but no notification appears and only a warning is logged (there is no mechanism to restrict a .vsix by platform)
  • Notification attribution: because it goes through osascript, the Notification Center shows the source as Script Editor (the exact wording varies by OS version). Grant permission with chirin: Send test notification first, then confirm it under System Settings → Notifications. Showing our own app name would require a signed .app and is not supported
  • Notifications disappear after a few seconds (banner): to keep them on screen, go to System Settings → Notifications → (the entry that appeared after the test notification) and choose "Alerts". The entry does not show up until a notification has been posted, so make this change after the test notification. It cannot be set from the config (display notification has no syntax for choosing banner or alert). The setting is per app, so notifications from other tools using the same osascript become alerts too. A "banner" still accumulates in the Notification Center (the difference is only whether it stays on screen)
  • No notification grouping: osascript cannot replace an existing notification, so notifications pile up. The default throttle (5 seconds) softens this
  • TCC-protected directories: watching anything under ~/Desktop, ~/Documents or ~/Downloads requires TCC permission, which means granting VS Code itself the equivalent of Full Disk Access. If you would rather not, keep the watch targets in an unprotected directory such as ~/src (chirin: Validate config warns about any affected target)
  • Focus mode (DND): notifications are not displayed during Focus. Accepted as designed
  • Event coalescing: with json-state, when several events occur within one poll interval only the latest is notified (a consequence of the single state file and last-write-wins)
  • "Run a command on match" will never be supported: a permanent non-goal, not a gap waiting for a pull request
  • At most one literal directory in an untrusted level: the symlink check covers only the single level directly above the watched file. With ~/src/*/*/*/.claude/chirin-notify-state.json, the container-writable level has one literal segment (.claude) and a swap is detected; with two or more, as in ~/src/*/logs/app/error.log, replacing an upper directory (logs) with a symlink can make chirin read a different file on the host (only up to each field's character limit reaches the notification, but it is still an information disclosure path). When watching a container-writable area, use a pattern with no deep literal hierarchy
  • Keep the config and lock outside container-writable mounts: owner/mode checks do not protect a file from a container that can write it through a mount. A config inside a local workspace warns at startup and on chirin: Validate config, but continues watching: opening a dotfiles folder locally does not establish that it is mounted into a container. This is advice based on the current window's folders, not proof of isolation. Remote workspace paths cannot be mapped to host paths, and that limitation is logged. Check mount settings yourself when moving the config away from ~/.config/chirin/. A container-writable lock directory lets an attacker keep a forged lock fresh and suppress notifications across all windows
  • Notification text is not authenticated: any process able to write a watched file can supply text under a configured title such as "Claude Code". Sanitization and length limits do not prove that Claude Code produced it. Verify requests in the originating tool before acting on a notification, especially requests to run commands or provide secrets
  • The in-window toast appears in the leader window: notifications are fired by the Watcher in the window holding the lock, so the chirin.showInEditorToast toast appears there (not in whichever other window has focus). Anything missed is covered by the OS notification, which always appears

Design notes

Everything below is reasoning that the code cannot show on its own. Anything that is visible in the code — module responsibilities, constants, the exact lock protocol — is documented in comments next to the code instead, so it cannot drift out of sync.

The name

Chirin (ちりん) is the sound a Japanese wind chime makes. A fūrin (風鈴, "wind bell") hangs under the eaves through the summer, and nobody strikes it: it rings only when the air moves. There is an old phrase for what a chime tells you — kaze no tayori (風の便り), "word on the wind": news that arrives from somewhere you cannot see. A devcontainer with its egress blocked is such a place.

chirin does not cut a window into that wall. It hangs a chime on the one thing that already passes through it, the bind-mounted workspace, and rings when Claude Code has something to say. A chime has no motor and nothing to say of its own; it only makes a change in the air audible. Most of what makes chirin trustworthy is likewise what it leaves out — no network, no dependencies, no execution — and the notes below explain why each absence is deliberate.

Architecture decisions

# Decision Rationale
1 The only delivery path is a file on the bind mount Preserves the egress block. No additional communication path (HTTP/SSH/socket) is created
2 A single state file overwritten, not an append log or a spool The point of a notification is to convey the latest state. last-write-wins is the correct specification, and it removes the whole complexity of a tailer (offset tracking, truncation detection, partial lines) and of a spool (cleanup, deduplication)
3 Writes are to a tmp file, then renamed within the same directory POSIX rename is atomic within a filesystem. The watcher always reads either the complete old version or the complete new one; a half-written state is never observed
4 Detection is readFile polling, not FS events On VirtioFS, events for container-side writes sometimes never reach the host. Keeping the update decision inside our own data (comparing the ts field) removes any dependence on the filesystem's notification mechanism
5 Zero npm dependencies (Node's standard library and the VS Code API only) Eliminates supply-chain risk and keeps the code auditable. A primary selling point
6 Notifications run /usr/bin/osascript with a fixed script plus argv The absolute path removes any dependence on PATH. The script body is a constant and data travels only through argv, which closes off AppleScript injection
7 The config lives in ~/.config/chirin/ (host side, outside the workspace) Inside the workspace, the container could tamper with the rules
8 On startup and when a file joins the watch set, only ts is recorded; nothing is notified. That reading happens as watching starts, not on the first poll. The one exception is a file first observed missing at a watched path: its appearance is itself a new event, and it notifies on the first valid read Prevents past events from notifying every time the extension host restarts, without dropping the very first notification after a hook is installed (the state file does not exist until the first event). Leaving the baseline to the first poll would misread anything written in that gap - up to a whole pollIntervalMs, exactly when a freshly installed hook fires - as pre-existing state
9 Runs as a UI extension (extensionKind: ["ui"]) A workspace extension runs on the in-container extension host, moving chirin itself onto the untrusted side and rendering both sanitization and the config permission checks meaningless
10 Multiple windows are handled by leader election through a lock file The extension host is one process per window and the throttle is an in-process Map. Without suppression, one event notifies once per window
11 Config changes are applied automatically, with every window polling the content Only the leader notifies, but each window holds the config as a snapshot taken at start(). Reloading anywhere but the leader would have no effect, and a promoted follower would run on a stale config. Running detection in every window makes the outcome independent of where the edit happened

Threat model

  • Trust boundary: the container is untrusted. Everything in the watched files is treated as attacker-controlled input
  • What is protected: the host's runtime environment. Code execution, injection and resource exhaustion using the notification app as a stepping stone must all be prevented
  • The boundary is redrawn per output sink: the same untrusted string needs different handling depending on whether it goes to osascript or to the VS Code notification API. "It is sanitized, so it is safe" is not enough — look at what the destination interprets, case by case
Vector Defense
AppleScript / shell injection through the notification body execFile on an absolute /usr/bin/osascript, so no shell is involved at all. The script body is a constant and the data travels only in argv, behind a -- that ends osascript's own option parsing — without it a message beginning with - could be read back as another -e. Building commands by string concatenation is banned outright
Link injection into the in-window toast (phishing) The VS Code notification API renders [label](https://github.com/Kei-Ikeda/chirin/blob/HEAD/url) in the body as a clickable link. ]( is broken right before display so it never forms link syntax
Control characters and escape sequences Sanitization (removing U+0000–U+001F, U+007F and the rest) plus length limits
Memory exhaustion through a huge file fstat after opening, then reject or read only the tail according to the per-source-type limit. A log-lines poll reads just the bytes appended past the offset it recorded last, never the whole file; config loading and change detection also have a 1MB read cap
CPU/memory exhaustion through many tiny log lines Select at most the newest 2,000 complete lines per file/poll before decoding, splitting or hashing; this also caps candidate events and retained deduplication hashes at 2,000. Line truncation stops at the character limit without allocating an array for the entire line
Huge or symlinked watcher lock Reads during acquisition, renewal and release require a regular file, reject symlinks/FIFOs and cap reads at 4KB; corrupt locks remain recoverable through the election protocol
Malicious input to a user-defined regex (ReDoS) The match target is capped at 200 characters and the pattern length at 256, and nested unbounded quantifiers are rejected at load. That check is only a heuristic — (a\|aa)+$ gets past it — so matching itself runs in a worker thread that is terminated once it exceeds its budget. Terminating the worker is the only thing that can stop a runaway match; no check on the evaluating thread can interrupt one
Config tampering / forged locks suppressing notifications Keep the config and lock directory outside container-writable mounts. Refuse config files or parent directories that are group/other writable or, on POSIX, owned by neither the current user nor root; refuse symlink config files. Workspace placement warnings are advisory and cannot establish isolation, especially in remote windows
Spoofed notification content / social engineering Watched files are untrusted and their producer is not authenticated. An attacker can supply text under a trusted configured title. Sanitization, length limits and broken link syntax limit rendering, but users must verify requests in the originating tool
Notification flooding A throttle per rule x file (default 5000ms) plus at most 5 notifications per poll cycle

Every limit and validation in the code is tied to this model. Do not remove one because it looks redundant.

The parts of the code that look stranger than they are

  • src/leader.ts — the lock operations (write to a private file then publish it with link, rename → validate → restore with link, an in-place write to an fd rather than a rename) are each a defense against a specific race that produces two leaders, i.e. duplicate notifications. Every one of them is explained in a comment at the point it happens. A lock operation that fails is also classified: a lost race leaves the window an ordinary follower, while a permission or I/O failure is reported as "cannot tell who is watching" rather than as healthy following. The heartbeat is deliberately decoupled from pollIntervalMs, so that all windows measure lock freshness with the same yardstick
  • src/regexMatcher.ts / src/regexWorker.ts — user-configured regular expressions run in a worker thread purely so a runaway match can be stopped
  • src/fileread.ts — O_NOFOLLOW / O_NONBLOCK plus an fstat after open exist to survive a symlink swap, a FIFO in place of a file, and a TOCTOU replacement between stat and read. The layer deliberately makes no decision about size limits; that belongs to the source adapters
  • src/glob.ts — the per-directory and per-pattern caps bound an attack where the container fills the watched hierarchy with directories. Truncation is always warned about, never silent
  • Notifications stopping silently is the worst failure. Several choices follow from that alone: a missing background_tasks counts as 0, a config that fails to parse keeps the previous one running, and the status bar shows a distinct state when it cannot tell whether any window is watching

Prior art

The closest design is DevContainer Host Notifier, which shares the skeleton: the container writes a file, a host-side UI extension reads it. claude-notifications and claude-code-notifier run the notification from the hook itself, which cannot reach the host from inside a container. claude-notifier covers remote setups by forwarding events over SSH — the natural approach when egress blocking is not a premise, and the reason chirin does not do it.

What chirin adds on top of the file-watching skeleton is rule- and template-based shaping, a single notification across multiple windows, source types that have nothing to do with Claude Code, and zero dependencies. It is worth using only where all three of "an egress-blocked devcontainer", "several windows open" and "no new dependencies" apply.

Development

npm ci
npm run check:zero-deps   # verify that dependencies is empty
npm run build             # tsc (src/*.ts, test/*.ts -> dist/**/*.js)
npm test                  # node:test (no added dependencies)
npm run package           # produce the .vsix

Opening this repository in VS Code and pressing F5 launches an extension development host window.

Only four files touch the VS Code API — extension.ts, commands.ts, vscodeLog.ts and vscodeNotifier.ts — and the core does not, which is what lets the tests run on node:test alone. That boundary is deliberate, so please do not import vscode into the core.

The notifier (real notifications) is outside the automated tests. Verify it by hand with chirin: Send test notification.

Localization

User-facing strings contributed through package.json (command titles, setting descriptions, the walkthrough) are localized through package.nls.json (English, the default) and package.nls.ja.json (Japanese); VS Code picks one based on its display language. The walkthrough panels live in media/walkthrough/ (English) and media/walkthrough/ja/ (Japanese). When adding a string, write it as %some.key% and add that key to both bundles; a key missing from the Japanese bundle falls back to the English one.

Everything else is English only — code comments, log output, runtime notification text and all documentation, this README included. The line is drawn at whether the two languages are structurally bound: a localized string has a sibling key in package.nls.json, and a localized walkthrough panel has a path the same bundle resolves, so letting one side rot breaks a lookup that shows up immediately. Free-standing prose has no such tie — a README.ja.md existed, drifted, and was removed.

Contributing

Issues and pull requests are welcome. Before opening one:

  • Run what CI runs, in the same order: npm run check:zero-deps && npm run build && npm test && npm run package. The two constraints it guards — an empty dependencies, and a core that never imports vscode — are explained under Development
  • Put reasoning next to the code it explains. Only reasoning the code cannot show — an architecture decision, a threat, a rejected alternative — belongs in Design notes, and no fact is written in two places
  • "Run a command on match" is a permanent non-goal, and a pull request adding it in any form will be declined

License

MIT

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft