opencode2-direnv 2026.904.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ <div align="center">
2
+
3
+ # opencode2-direnv
4
+
5
+ **Seamless direnv integration for OpenCode 2**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/opencode2-direnv?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/opencode2-direnv)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT)
9
+ [![direnv](https://img.shields.io/badge/direnv-compatible-FFC107?style=flat-square&logo=gnubash&logoColor=white)](https://direnv.net/)
10
+
11
+ ---
12
+
13
+ *Automatically load [direnv](https://direnv.net/) environment variables into OpenCode 2 sessions — and keep them in sync while you work.*
14
+
15
+ Fork of [`@simonwjackson/opencode-direnv`](https://github.com/simonwjackson/opencode-direnv), ported to the OpenCode 2 plugin API. OpenCode 1 users should use the original package.
16
+
17
+ </div>
18
+
19
+ ---
20
+
21
+ ## Overview
22
+
23
+ The plugin detects `.envrc` files and keeps the OpenCode server environment in sync with your devshell:
24
+
25
+ - **Automatic Detection** — Searches for `.envrc` from the project directory up to the git root
26
+ - **Shell Injection** — Injects the current direnv export into every shell OpenCode spawns (via the `shell.create.before` hook), so agent commands always see the devshell
27
+ - **Live Reloading** — Re-applies the devshell when `.envrc`/`flake.nix`/`flake.lock` change (debounced) or a new session starts, and *removes* variables that are dropped
28
+ - **Fallback Sync** — Also reconciles `process.env` so other subprocesses (LSP, MCP) inherit the devshell
29
+ - **Graceful Degradation** — Silently skips if direnv is not installed or no `.envrc` exists; warns in the logs when a `.envrc` is blocked
30
+
31
+ ## Requirements
32
+
33
+ - [OpenCode 2](https://opencode.ai/) (`opencode2`, beta)
34
+ - [direnv](https://direnv.net/) >= 2.0 in PATH
35
+
36
+ ## Installation
37
+
38
+ Add the plugin to your OpenCode 2 configuration (`plugins`, not the v1 `plugin` key):
39
+
40
+ **Project-level** (`./opencode.json`):
41
+
42
+ ```json
43
+ {
44
+ "$schema": "https://opencode.ai/config.json",
45
+ "plugins": ["opencode2-direnv"]
46
+ }
47
+ ```
48
+
49
+ **Global** (`~/.config/opencode/opencode.json`):
50
+
51
+ ```json
52
+ {
53
+ "plugins": ["opencode2-direnv"]
54
+ }
55
+ ```
56
+
57
+ Then allow your `.envrc` once:
58
+
59
+ ```bash
60
+ direnv allow
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ Start `opencode2` in a project with an `.envrc`. Environment variables are loaded at startup, re-synced per session, and reloaded (debounced ~1.5s) when devshell files change. Outcomes are logged with a `direnv:` prefix:
66
+
67
+ ```
68
+ direnv: environment loaded
69
+ direnv: reloaded (+2 ~1)
70
+ direnv: .envrc is blocked. Run `direnv allow` to enable.
71
+ ```
72
+
73
+ ### Local development
74
+
75
+ Local plugins load from a directory containing an `index.ts` entrypoint:
76
+
77
+ ```json
78
+ {
79
+ "plugins": ["/path/to/opencode-direnv"]
80
+ }
81
+ ```
82
+
83
+ The root `index.ts` re-exports `src/index.ts`, so edits are picked up on restart. Typecheck and build with:
84
+
85
+ ```bash
86
+ npm ci
87
+ npx tsc --noEmit
88
+ npm run build
89
+ ```
90
+
91
+ ## How It Works
92
+
93
+ ```
94
+ plugin setup ────▶ direnv export json ──▶ process.env reconcile
95
+ │ │
96
+ ├── shell.create.before hook ────────────┴─▶ every spawned shell gets the devshell
97
+ ├── session.created event ──▶ re-sync per session (e.g. after `direnv allow`)
98
+ └── filesystem.changed event (.envrc/flake.nix/flake.lock) ──▶ debounced reload
99
+ ```
100
+
101
+ 1. **Discovery** — Searches upward from the plugin's location directory, stopping at the git root
102
+ 2. **Export** — Runs `direnv export json` (fast when nothing changed; direnv's own watch caches it)
103
+ 3. **Reconcile** — Applies additions/changes and *removes* dropped keys; explicit direnv unsets (`null` values) are honored
104
+ 4. **Inject** — The `shell.create.before` hook copies the cached export into the environment of every shell spawned below the devshell directory
105
+
106
+ ## Troubleshooting
107
+
108
+ ```bash
109
+ # Verify direnv is installed and .envrc is allowed
110
+ which direnv && direnv status
111
+
112
+ # Verify the export works in the project directory
113
+ direnv export json
114
+ ```
115
+
116
+ If the plugin does not activate, check the server logs (`opencode2 run --standalone --print-logs --log-level debug ...`) for `direnv:` lines or plugin load errors.
117
+
118
+ ## Limitations
119
+
120
+ - Reloads are debounced (~1.5s); run `direnv reload` for an immediate refresh
121
+ - `.envrc` must be explicitly allowed (`direnv allow`) — a blocked file is reported in the logs
122
+ - A changed `use flake` re-evaluates in the background; the session is never blocked
123
+
124
+ ## License
125
+
126
+ [MIT](LICENSE) — originally by [@simonwjackson](https://github.com/simonwjackson), ported to the OpenCode 2 plugin API by [@ankarhem](https://github.com/ankarhem).
@@ -0,0 +1,4 @@
1
+ import { Plugin } from "@opencode-ai/plugin";
2
+ declare const _default: Plugin.Plugin;
3
+ export default _default;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;;AAmE5C,wBAkRE"}
package/dist/index.js ADDED
@@ -0,0 +1,293 @@
1
+ import { Plugin } from "@opencode-ai/plugin";
2
+ import { execFile } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ /**
7
+ * Direnv Auto-Loader Plugin for OpenCode 2
8
+ *
9
+ * Automatically loads AND keeps in sync environment variables from direnv.
10
+ * Shell commands run by the agent always see the current devshell state, even
11
+ * after the user edits .envrc/flake.nix or runs `direnv reload` mid-session.
12
+ *
13
+ * Behavior:
14
+ * - On plugin setup: load env once (awaited, so the first command is ready)
15
+ * - On session.created: re-sync per new session (e.g. after `direnv allow`)
16
+ * - On filesystem.changed for .envrc/flake.nix/flake.lock: debounced reload
17
+ * - Injects the latest direnv export into every spawned shell via the
18
+ * `shell.create.before` hook (primary mechanism)
19
+ * - Reconciles process.env as a fallback for other subprocesses (LSP, MCP) and
20
+ * *removes* dropped keys so removed variables don't linger
21
+ * - Logs outcomes with a `direnv:` prefix (blocked .envrc is always reported)
22
+ * - Silently skips if direnv is not installed or .envrc is missing
23
+ */
24
+ const run = promisify(execFile);
25
+ /** devshell files whose modification should trigger a reload */
26
+ const RELEVANT_FILES = new Set([".envrc", "flake.nix", "flake.lock"]);
27
+ /** debounce window for background reloads (ms) */
28
+ const RELOAD_DEBOUNCE_MS = 1500;
29
+ const runText = async (file, args, cwd) => {
30
+ const { stdout } = await run(file, args, {
31
+ cwd,
32
+ encoding: "utf8",
33
+ maxBuffer: 10 * 1024 * 1024,
34
+ });
35
+ return stdout;
36
+ };
37
+ export default Plugin.define({
38
+ id: "opencode2-direnv",
39
+ async setup(ctx) {
40
+ const directory = ctx.location.directory;
41
+ /**
42
+ * Sessions already synced by this plugin instance. Events arrive for every
43
+ * location, so also filter by event location where one is present.
44
+ */
45
+ const loadedSessions = new Set();
46
+ /**
47
+ * Keys we have written into process.env. Tracked globally (process.env is
48
+ * shared across locations in the server process) so reloads can remove vars
49
+ * that the devshell no longer exports without touching anything we didn't set.
50
+ */
51
+ const appliedKeys = new Set();
52
+ /** latest successful `direnv export json` payload, injected into spawned shells */
53
+ let currentVars = {};
54
+ /** cached .envrc location (only cached once successfully found) */
55
+ let envrcDir = null;
56
+ let discovered = false;
57
+ /** prevents overlapping `direnv export json` invocations */
58
+ let reloading = false;
59
+ let reloadTimer = null;
60
+ let firstLoadComplete = false;
61
+ const findGitRoot = async () => {
62
+ try {
63
+ const result = await runText("git", ["rev-parse", "--show-toplevel"], directory);
64
+ return result.trim() || null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ };
70
+ /**
71
+ * Find .envrc file searching from directory up to stopAt (git root or filesystem root)
72
+ */
73
+ const findEnvrc = async (startDir, stopAt) => {
74
+ let current = startDir;
75
+ const boundary = stopAt || "/";
76
+ while (true) {
77
+ if (existsSync(join(current, ".envrc"))) {
78
+ return join(current, ".envrc");
79
+ }
80
+ if (current === boundary || current === "/") {
81
+ break;
82
+ }
83
+ const parent = dirname(current);
84
+ if (parent === current) {
85
+ break;
86
+ }
87
+ current = parent;
88
+ }
89
+ return null;
90
+ };
91
+ /**
92
+ * Resolve (and cache) the directory containing .envrc.
93
+ * Caches only on success; a missing .envrc is re-checked on later triggers
94
+ * so one created mid-session is eventually picked up.
95
+ */
96
+ const resolveEnvrcDir = async () => {
97
+ if (discovered)
98
+ return envrcDir;
99
+ const gitRoot = await findGitRoot();
100
+ const envrcPath = await findEnvrc(directory, gitRoot);
101
+ if (envrcPath) {
102
+ envrcDir = dirname(envrcPath);
103
+ }
104
+ discovered = envrcDir !== null;
105
+ return envrcDir;
106
+ };
107
+ const newOutcome = () => ({
108
+ blocked: false,
109
+ unavailable: false,
110
+ error: false,
111
+ added: 0,
112
+ changed: 0,
113
+ removed: 0,
114
+ });
115
+ /**
116
+ * Re-run `direnv export json` and reconcile process.env.
117
+ *
118
+ * Idempotent and mutex-guarded: safe to call from any trigger. Cheap when
119
+ * nothing changed (direnv's own watch cache makes the export ~milliseconds).
120
+ * Returns a summary describing what (if anything) changed.
121
+ */
122
+ const reloadEnv = async () => {
123
+ const outcome = newOutcome();
124
+ if (reloading)
125
+ return outcome;
126
+ reloading = true;
127
+ try {
128
+ const dir = await resolveEnvrcDir();
129
+ if (!dir) {
130
+ outcome.unavailable = true;
131
+ return outcome;
132
+ }
133
+ let jsonText;
134
+ try {
135
+ jsonText = await runText("direnv", ["export", "json"], dir);
136
+ }
137
+ catch (error) {
138
+ const stderr = error && typeof error === "object" && "stderr" in error
139
+ ? String(error.stderr ?? "")
140
+ : "";
141
+ if (stderr.includes("is blocked")) {
142
+ outcome.blocked = true;
143
+ }
144
+ else {
145
+ outcome.error = true;
146
+ }
147
+ return outcome;
148
+ }
149
+ const parsed = jsonText.trim() ? JSON.parse(jsonText) : {};
150
+ const newVars = parsed && typeof parsed === "object" ? parsed : {};
151
+ // 1. Remove vars we previously applied that the devshell no longer exports.
152
+ // direnv may also emit explicit `null` values to signal unsets.
153
+ for (const key of appliedKeys) {
154
+ const keep = key in newVars && newVars[key] != null;
155
+ if (!keep && key in process.env) {
156
+ delete process.env[key];
157
+ outcome.removed++;
158
+ }
159
+ }
160
+ // 2. Apply current vars, counting additions and value changes.
161
+ const nextApplied = new Set();
162
+ for (const [key, value] of Object.entries(newVars)) {
163
+ if (value == null)
164
+ continue;
165
+ const current = process.env[key];
166
+ if (current === undefined)
167
+ outcome.added++;
168
+ else if (current !== value)
169
+ outcome.changed++;
170
+ process.env[key] = value;
171
+ nextApplied.add(key);
172
+ }
173
+ appliedKeys.clear();
174
+ for (const key of nextApplied)
175
+ appliedKeys.add(key);
176
+ currentVars = newVars;
177
+ return outcome;
178
+ }
179
+ catch {
180
+ outcome.error = true;
181
+ return outcome;
182
+ }
183
+ finally {
184
+ reloading = false;
185
+ }
186
+ };
187
+ /**
188
+ * Surface a reload result via logs.
189
+ *
190
+ * - blocked: always warn (action required by the user)
191
+ * - first load: confirm the environment was applied
192
+ * - subsequent: only log when something actually changed
193
+ * - no-op / unavailable / transient error: silent
194
+ */
195
+ const logOutcome = (outcome, opts) => {
196
+ if (outcome.blocked) {
197
+ console.warn("direnv: .envrc is blocked. Run `direnv allow` to enable.");
198
+ return;
199
+ }
200
+ if (outcome.unavailable || outcome.error)
201
+ return;
202
+ const total = outcome.added + outcome.changed + outcome.removed;
203
+ if (opts.initial && !firstLoadComplete) {
204
+ firstLoadComplete = true;
205
+ if (total > 0 || appliedKeys.size > 0) {
206
+ console.log("direnv: environment loaded");
207
+ }
208
+ return;
209
+ }
210
+ if (total > 0) {
211
+ const parts = [];
212
+ if (outcome.added)
213
+ parts.push(`+${outcome.added}`);
214
+ if (outcome.changed)
215
+ parts.push(`~${outcome.changed}`);
216
+ if (outcome.removed)
217
+ parts.push(`-${outcome.removed}`);
218
+ console.log(`direnv: reloaded (${parts.join(" ")})`);
219
+ }
220
+ };
221
+ /**
222
+ * Debounced background reload. Collapses rapid triggers (e.g. many file
223
+ * writes from an editor save) into a single reload.
224
+ */
225
+ const scheduleReload = (delayMs) => {
226
+ if (reloadTimer)
227
+ clearTimeout(reloadTimer);
228
+ reloadTimer = setTimeout(() => {
229
+ reloadTimer = null;
230
+ void reloadEnv().then((outcome) => logOutcome(outcome, { initial: false }));
231
+ }, delayMs);
232
+ };
233
+ // Initial load: awaited so the first command of any session sees the env.
234
+ logOutcome(await reloadEnv(), { initial: true });
235
+ // Primary mechanism: inject the latest direnv export into every shell the
236
+ // server spawns for a working copy inside the devshell. The cached payload
237
+ // keeps the hook synchronous and non-blocking.
238
+ const shellRegistration = await ctx.shell.hook("create.before", (event) => {
239
+ if (!envrcDir)
240
+ return;
241
+ if (event.cwd !== envrcDir && !event.cwd.startsWith(`${envrcDir}/`))
242
+ return;
243
+ for (const [key, value] of Object.entries(currentVars)) {
244
+ event.env[key] = value;
245
+ }
246
+ });
247
+ const isRelevantLocation = (location) => !location?.directory || location.directory === directory;
248
+ // Event stream: per-session sync and devshell-file watch. Runs detached so
249
+ // plugin setup is never blocked; aborted on cleanup.
250
+ const controller = new AbortController();
251
+ void (async () => {
252
+ try {
253
+ for await (const raw of ctx.event.subscribe({ signal: controller.signal })) {
254
+ const event = raw;
255
+ // Re-sync on new sessions (catches `direnv allow` and other state
256
+ // changes that don't touch watched files).
257
+ if (event.type === "session.created") {
258
+ const data = event.data;
259
+ const sessionID = data?.sessionID;
260
+ if (!sessionID || !isRelevantLocation(event.location))
261
+ continue;
262
+ if (loadedSessions.has(sessionID))
263
+ continue;
264
+ loadedSessions.add(sessionID);
265
+ const outcome = await reloadEnv();
266
+ logOutcome(outcome, { initial: false });
267
+ continue;
268
+ }
269
+ // Devshell file edited/created/deleted -> reload (debounced).
270
+ if (event.type === "filesystem.changed") {
271
+ const data = event.data;
272
+ const filename = (data?.file ?? "").split("/").pop() ?? "";
273
+ if (!isRelevantLocation(event.location))
274
+ continue;
275
+ if (RELEVANT_FILES.has(filename)) {
276
+ scheduleReload(RELOAD_DEBOUNCE_MS);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ catch {
282
+ // stream aborted on cleanup or failed; nothing to do
283
+ }
284
+ })();
285
+ return () => {
286
+ controller.abort();
287
+ if (reloadTimer)
288
+ clearTimeout(reloadTimer);
289
+ void shellRegistration.dispose();
290
+ };
291
+ },
292
+ });
293
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.js"
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "opencode2-direnv",
3
+ "version": "2026.0904.0",
4
+ "description": "OpenCode 2 plugin that automatically loads direnv environment variables",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "index.ts"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc"
18
+ },
19
+ "keywords": [
20
+ "opencode",
21
+ "opencode2",
22
+ "direnv",
23
+ "plugin",
24
+ "nix",
25
+ "environment",
26
+ "env"
27
+ ],
28
+ "author": "ankarhem",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/ankarhem/opencode-direnv.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/ankarhem/opencode-direnv/issues"
36
+ },
37
+ "homepage": "https://github.com/ankarhem/opencode-direnv#readme",
38
+ "peerDependencies": {
39
+ "@opencode-ai/plugin": "beta"
40
+ },
41
+ "devDependencies": {
42
+ "@opencode-ai/plugin": "^0.0.0-beta-19086",
43
+ "@types/node": "^22.0.0",
44
+ "typescript": "^5.9.3"
45
+ }
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,342 @@
1
+ import { Plugin } from "@opencode-ai/plugin"
2
+ import { execFile } from "node:child_process"
3
+ import { existsSync } from "node:fs"
4
+ import { dirname, join } from "node:path"
5
+ import { promisify } from "node:util"
6
+
7
+ /**
8
+ * Direnv Auto-Loader Plugin for OpenCode 2
9
+ *
10
+ * Automatically loads AND keeps in sync environment variables from direnv.
11
+ * Shell commands run by the agent always see the current devshell state, even
12
+ * after the user edits .envrc/flake.nix or runs `direnv reload` mid-session.
13
+ *
14
+ * Behavior:
15
+ * - On plugin setup: load env once (awaited, so the first command is ready)
16
+ * - On session.created: re-sync per new session (e.g. after `direnv allow`)
17
+ * - On filesystem.changed for .envrc/flake.nix/flake.lock: debounced reload
18
+ * - Injects the latest direnv export into every spawned shell via the
19
+ * `shell.create.before` hook (primary mechanism)
20
+ * - Reconciles process.env as a fallback for other subprocesses (LSP, MCP) and
21
+ * *removes* dropped keys so removed variables don't linger
22
+ * - Logs outcomes with a `direnv:` prefix (blocked .envrc is always reported)
23
+ * - Silently skips if direnv is not installed or .envrc is missing
24
+ */
25
+
26
+ const run = promisify(execFile)
27
+
28
+ type ReloadOutcome = {
29
+ /** .envrc exists but is blocked (`direnv allow` needed) */
30
+ blocked: boolean
31
+ /** direnv not installed / no .envrc / discovery failed */
32
+ unavailable: boolean
33
+ /** a transient error occurred talking to direnv */
34
+ error: boolean
35
+ /** number of brand-new variables added to process.env */
36
+ added: number
37
+ /** number of existing variables whose value changed */
38
+ changed: number
39
+ /** number of previously-applied variables that were removed */
40
+ removed: number
41
+ }
42
+
43
+ /** Minimal shapes of the v2 server events this plugin reacts to */
44
+ type EncodedEvent = {
45
+ readonly type: string
46
+ readonly data?: unknown
47
+ readonly location?: { readonly directory?: string } | undefined
48
+ }
49
+
50
+ type SessionCreatedData = { readonly sessionID?: string }
51
+ type FilesystemChangedData = { readonly file?: string }
52
+
53
+ /** devshell files whose modification should trigger a reload */
54
+ const RELEVANT_FILES = new Set([".envrc", "flake.nix", "flake.lock"])
55
+
56
+ /** debounce window for background reloads (ms) */
57
+ const RELOAD_DEBOUNCE_MS = 1500
58
+
59
+ const runText = async (file: string, args: string[], cwd: string) => {
60
+ const { stdout } = await run(file, args, {
61
+ cwd,
62
+ encoding: "utf8",
63
+ maxBuffer: 10 * 1024 * 1024,
64
+ })
65
+ return stdout
66
+ }
67
+
68
+ export default Plugin.define({
69
+ id: "opencode2-direnv",
70
+
71
+ async setup(ctx) {
72
+ const directory = ctx.location.directory
73
+
74
+ /**
75
+ * Sessions already synced by this plugin instance. Events arrive for every
76
+ * location, so also filter by event location where one is present.
77
+ */
78
+ const loadedSessions = new Set<string>()
79
+
80
+ /**
81
+ * Keys we have written into process.env. Tracked globally (process.env is
82
+ * shared across locations in the server process) so reloads can remove vars
83
+ * that the devshell no longer exports without touching anything we didn't set.
84
+ */
85
+ const appliedKeys = new Set<string>()
86
+
87
+ /** latest successful `direnv export json` payload, injected into spawned shells */
88
+ let currentVars: Record<string, string> = {}
89
+
90
+ /** cached .envrc location (only cached once successfully found) */
91
+ let envrcDir: string | null = null
92
+ let discovered = false
93
+
94
+ /** prevents overlapping `direnv export json` invocations */
95
+ let reloading = false
96
+
97
+ let reloadTimer: ReturnType<typeof setTimeout> | null = null
98
+ let firstLoadComplete = false
99
+
100
+ const findGitRoot = async (): Promise<string | null> => {
101
+ try {
102
+ const result = await runText("git", ["rev-parse", "--show-toplevel"], directory)
103
+ return result.trim() || null
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Find .envrc file searching from directory up to stopAt (git root or filesystem root)
111
+ */
112
+ const findEnvrc = async (
113
+ startDir: string,
114
+ stopAt: string | null
115
+ ): Promise<string | null> => {
116
+ let current = startDir
117
+ const boundary = stopAt || "/"
118
+
119
+ while (true) {
120
+ if (existsSync(join(current, ".envrc"))) {
121
+ return join(current, ".envrc")
122
+ }
123
+
124
+ if (current === boundary || current === "/") {
125
+ break
126
+ }
127
+
128
+ const parent = dirname(current)
129
+ if (parent === current) {
130
+ break
131
+ }
132
+
133
+ current = parent
134
+ }
135
+
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Resolve (and cache) the directory containing .envrc.
141
+ * Caches only on success; a missing .envrc is re-checked on later triggers
142
+ * so one created mid-session is eventually picked up.
143
+ */
144
+ const resolveEnvrcDir = async (): Promise<string | null> => {
145
+ if (discovered) return envrcDir
146
+ const gitRoot = await findGitRoot()
147
+ const envrcPath = await findEnvrc(directory, gitRoot)
148
+ if (envrcPath) {
149
+ envrcDir = dirname(envrcPath)
150
+ }
151
+ discovered = envrcDir !== null
152
+ return envrcDir
153
+ }
154
+
155
+ const newOutcome = (): ReloadOutcome => ({
156
+ blocked: false,
157
+ unavailable: false,
158
+ error: false,
159
+ added: 0,
160
+ changed: 0,
161
+ removed: 0,
162
+ })
163
+
164
+ /**
165
+ * Re-run `direnv export json` and reconcile process.env.
166
+ *
167
+ * Idempotent and mutex-guarded: safe to call from any trigger. Cheap when
168
+ * nothing changed (direnv's own watch cache makes the export ~milliseconds).
169
+ * Returns a summary describing what (if anything) changed.
170
+ */
171
+ const reloadEnv = async (): Promise<ReloadOutcome> => {
172
+ const outcome = newOutcome()
173
+ if (reloading) return outcome
174
+ reloading = true
175
+
176
+ try {
177
+ const dir = await resolveEnvrcDir()
178
+ if (!dir) {
179
+ outcome.unavailable = true
180
+ return outcome
181
+ }
182
+
183
+ let jsonText: string
184
+ try {
185
+ jsonText = await runText("direnv", ["export", "json"], dir)
186
+ } catch (error: unknown) {
187
+ const stderr =
188
+ error && typeof error === "object" && "stderr" in error
189
+ ? String((error as { stderr?: string }).stderr ?? "")
190
+ : ""
191
+ if (stderr.includes("is blocked")) {
192
+ outcome.blocked = true
193
+ } else {
194
+ outcome.error = true
195
+ }
196
+ return outcome
197
+ }
198
+
199
+ const parsed = jsonText.trim() ? JSON.parse(jsonText) : {}
200
+ const newVars: Record<string, string> =
201
+ parsed && typeof parsed === "object" ? parsed : {}
202
+
203
+ // 1. Remove vars we previously applied that the devshell no longer exports.
204
+ // direnv may also emit explicit `null` values to signal unsets.
205
+ for (const key of appliedKeys) {
206
+ const keep = key in newVars && newVars[key] != null
207
+ if (!keep && key in process.env) {
208
+ delete process.env[key]
209
+ outcome.removed++
210
+ }
211
+ }
212
+
213
+ // 2. Apply current vars, counting additions and value changes.
214
+ const nextApplied = new Set<string>()
215
+ for (const [key, value] of Object.entries(newVars)) {
216
+ if (value == null) continue
217
+ const current = process.env[key]
218
+ if (current === undefined) outcome.added++
219
+ else if (current !== value) outcome.changed++
220
+ process.env[key] = value
221
+ nextApplied.add(key)
222
+ }
223
+
224
+ appliedKeys.clear()
225
+ for (const key of nextApplied) appliedKeys.add(key)
226
+ currentVars = newVars
227
+
228
+ return outcome
229
+ } catch {
230
+ outcome.error = true
231
+ return outcome
232
+ } finally {
233
+ reloading = false
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Surface a reload result via logs.
239
+ *
240
+ * - blocked: always warn (action required by the user)
241
+ * - first load: confirm the environment was applied
242
+ * - subsequent: only log when something actually changed
243
+ * - no-op / unavailable / transient error: silent
244
+ */
245
+ const logOutcome = (outcome: ReloadOutcome, opts: { initial: boolean }) => {
246
+ if (outcome.blocked) {
247
+ console.warn("direnv: .envrc is blocked. Run `direnv allow` to enable.")
248
+ return
249
+ }
250
+ if (outcome.unavailable || outcome.error) return
251
+
252
+ const total = outcome.added + outcome.changed + outcome.removed
253
+
254
+ if (opts.initial && !firstLoadComplete) {
255
+ firstLoadComplete = true
256
+ if (total > 0 || appliedKeys.size > 0) {
257
+ console.log("direnv: environment loaded")
258
+ }
259
+ return
260
+ }
261
+
262
+ if (total > 0) {
263
+ const parts: string[] = []
264
+ if (outcome.added) parts.push(`+${outcome.added}`)
265
+ if (outcome.changed) parts.push(`~${outcome.changed}`)
266
+ if (outcome.removed) parts.push(`-${outcome.removed}`)
267
+ console.log(`direnv: reloaded (${parts.join(" ")})`)
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Debounced background reload. Collapses rapid triggers (e.g. many file
273
+ * writes from an editor save) into a single reload.
274
+ */
275
+ const scheduleReload = (delayMs: number) => {
276
+ if (reloadTimer) clearTimeout(reloadTimer)
277
+ reloadTimer = setTimeout(() => {
278
+ reloadTimer = null
279
+ void reloadEnv().then((outcome) => logOutcome(outcome, { initial: false }))
280
+ }, delayMs)
281
+ }
282
+
283
+ // Initial load: awaited so the first command of any session sees the env.
284
+ logOutcome(await reloadEnv(), { initial: true })
285
+
286
+ // Primary mechanism: inject the latest direnv export into every shell the
287
+ // server spawns for a working copy inside the devshell. The cached payload
288
+ // keeps the hook synchronous and non-blocking.
289
+ const shellRegistration = await ctx.shell.hook("create.before", (event) => {
290
+ if (!envrcDir) return
291
+ if (event.cwd !== envrcDir && !event.cwd.startsWith(`${envrcDir}/`)) return
292
+ for (const [key, value] of Object.entries(currentVars)) {
293
+ event.env[key] = value
294
+ }
295
+ })
296
+
297
+ const isRelevantLocation = (location: EncodedEvent["location"]) =>
298
+ !location?.directory || location.directory === directory
299
+
300
+ // Event stream: per-session sync and devshell-file watch. Runs detached so
301
+ // plugin setup is never blocked; aborted on cleanup.
302
+ const controller = new AbortController()
303
+ void (async () => {
304
+ try {
305
+ for await (const raw of ctx.event.subscribe({ signal: controller.signal })) {
306
+ const event = raw as unknown as EncodedEvent
307
+
308
+ // Re-sync on new sessions (catches `direnv allow` and other state
309
+ // changes that don't touch watched files).
310
+ if (event.type === "session.created") {
311
+ const data = event.data as SessionCreatedData | undefined
312
+ const sessionID = data?.sessionID
313
+ if (!sessionID || !isRelevantLocation(event.location)) continue
314
+ if (loadedSessions.has(sessionID)) continue
315
+ loadedSessions.add(sessionID)
316
+ const outcome = await reloadEnv()
317
+ logOutcome(outcome, { initial: false })
318
+ continue
319
+ }
320
+
321
+ // Devshell file edited/created/deleted -> reload (debounced).
322
+ if (event.type === "filesystem.changed") {
323
+ const data = event.data as FilesystemChangedData | undefined
324
+ const filename = (data?.file ?? "").split("/").pop() ?? ""
325
+ if (!isRelevantLocation(event.location)) continue
326
+ if (RELEVANT_FILES.has(filename)) {
327
+ scheduleReload(RELOAD_DEBOUNCE_MS)
328
+ }
329
+ }
330
+ }
331
+ } catch {
332
+ // stream aborted on cleanup or failed; nothing to do
333
+ }
334
+ })()
335
+
336
+ return () => {
337
+ controller.abort()
338
+ if (reloadTimer) clearTimeout(reloadTimer)
339
+ void shellRegistration.dispose()
340
+ }
341
+ },
342
+ })