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.
Quick startEverything happens on the host, in the Command Palette (
That is the whole setup.
What it ischirin 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 (
How it works
This is a UI extension ( Setup in detailRequirements
Installing from a
|
| 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):
- Places
.claude/hooks/chirin-notify.sh - Merges into the
hookssection 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) - Appends a
# chirinblock to the end of.gitignore, untracking the three files chirin writes into the workspace:.claude/hooks/chirin-notify.sh,.claude/chirin-notify-state.jsonand.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 callingnotify.shfrom.claude/settings.local.json, the three old files themselves, and the two stale lines left in the# chirinblock of.gitignore. Then changenotify-state.jsontochirin-notify-state.jsonin the host-side config'swatchpatterns, and confirm withchirin: Validate configthat the targets appear undertargets 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
pollIntervalMsis set. A lock left behind by a crash is never a deadlock chirin.configPathisscope: 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.lockup 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 leftoverwatcher.lockis 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 projectto pick them up — an olderchirin-notify.shwrites 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
.vsixby 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 notificationfirst, 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 notificationhas 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,~/Documentsor~/Downloadsrequires 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 configwarns 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.showInEditorToasttoast 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 withlink,rename→ validate → restore withlink, 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 frompollIntervalMs, so that all windows measure lock freshness with the same yardsticksrc/regexMatcher.ts/src/regexWorker.ts— user-configured regular expressions run in a worker thread purely so a runaway match can be stoppedsrc/fileread.ts—O_NOFOLLOW/O_NONBLOCKplus anfstatafter 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 adapterssrc/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_taskscounts 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 emptydependencies, and a core that never importsvscode— 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