# devsess

The Effect-based core: `DevSessions` and `CurrentSession` (services + layers), and free functions for sticky ports, managed subprocesses, and cross-package running signals. See [Writing a dev CLI](/writing-a-dev-cli) for how these fit around a stock `Command.make(...)`, and [Running your dev server](/recipes/dev-server) for subprocess lifetime.

Peer: `effect` (`^4.0.0-beta.101`). No platform package is a peer — `devsess` depends on neither; provide something satisfying `FileSystem | Path` yourself (both `@effect/platform-node`'s `NodeServices.layer` and `@effect/platform-bun`'s `BunServices.layer` do, since each is a superset).

```bash
bun add devsess effect @effect/platform-node
```

```bash
bun add devsess effect @effect/platform-bun
```

## `DevSessions`

`Context.Service` tag for the session store.

```ts
class DevSessions extends Context.Service<
	DevSessions,
	{
		readonly dir: string
		readonly path: (relativePath: string) => string
		readonly getSessions: Effect.Effect<Array<DevSession>, PlatformError>
		readonly createSession: Effect.Effect<DevSession, PlatformError>
		readonly getLatestOrCreate: Effect.Effect<DevSession, PlatformError>
	}
>()('devsess/DevSessions') {
	static readonly layerAt: (rootDir: string) => Layer.Layer<DevSessions, never, FileSystem | Path>
	static readonly layer: Layer.Layer<DevSessions, PlatformError | ProjectRootNotFoundError, FileSystem | Path>
}
```

| Member | Type | Description |
|---|---|---|
| `dir` | `string` | The root this instance was built with — `rootDir` verbatim, not the sessions subdirectory |
| `path(relativePath)` | `(relativePath: string) => string` | Joins `relativePath` onto `dir` — a project file, not a session file |
| `getSessions` | `Effect.Effect<Array<DevSession>, PlatformError>` | Lists session subdirectories with their mtimes. `[]` if the root doesn't exist yet — not an error |
| `createSession` | `Effect.Effect<DevSession, PlatformError>` | Creates a new session directory with a fresh slug |
| `getLatestOrCreate` | `Effect.Effect<DevSession, PlatformError>` | Ensures the root exists, then returns the session with the newest directory mtime — or creates one if none exist |

`getSessions`, `createSession`, and `getLatestOrCreate` are Effects already bound to the resolved service instance, not functions — read them off the service, e.g. `(yield* DevSessions).getLatestOrCreate`.

**`DevSessions.layerAt(rootDir)`** — rooted at `rootDir` explicitly: `dir`/`path` resolve against `rootDir` itself (e.g. `sessions.path('drizzle')` reaches a project's real `drizzle/` folder), while session directories are namespaced under `<rootDir>/.data/sessions`. No directory I/O happens when the layer is built, only when its methods run.

**`DevSessions.layer`** — `layerAt` rooted at the auto-detected project root: the nearest ancestor of `process.cwd()` containing a `package.json`, found by walking up one directory at a time. Fails with `ProjectRootNotFoundError` if no ancestor has one, instead of silently falling back to `process.cwd()`.

Deliberately does **not** also provide `CurrentSession` — see [`CurrentSession`](#currentsession) below for why.

## `ProjectRootNotFoundError`

```ts
class ProjectRootNotFoundError extends Data.TaggedError('ProjectRootNotFoundError')<{
	readonly searchedFrom: string
}> {}
```

Raised by `DevSessions.layer` when no ancestor of `searchedFrom` (`process.cwd()` at the start of the search) contains a `package.json`.

## `CurrentSession`

`Context.Service` tag holding the session resolved for a single command run.

```ts
class CurrentSession extends Context.Service<CurrentSession, DevSession>()('devsess/CurrentSession') {
	static readonly layer: Layer.Layer<CurrentSession, PlatformError, DevSessions>
	static readonly layerOf: (session: DevSession) => Layer.Layer<CurrentSession, never, never>
}
```

`yield* CurrentSession` always gives a plain `DevSession` — never a wrapper effect to unwrap.

**`CurrentSession.layer`** — resolves the session via `DevSessions#getLatestOrCreate`, cached for the run via layer memoization (two `yield* CurrentSession` in the same provided scope resolve the identical session, one lookup). Provide this around a command's own handler effect, not the whole CLI program: a `Layer`'s build effect runs the moment it's provided, so wrapping `Command.run` itself would create a session for `--help`, `--version`, or a bad flag too, none of which ever reach a handler.

**`CurrentSession.layerOf(session)`** — pins an explicit session instead of resolving one via `DevSessions`. For tests.

## `DevSession`

```ts
type DevSession = {
	name: string
	lastModifiedAt: Date | null
	path: (relativePath: string) => Effect.Effect<string, never, never>
	toString: () => string
}
```

| Member | Description |
|---|---|
| `name` | Session slug — a single noun, e.g. `walrus` |
| `lastModifiedAt` | Directory mtime as of the last listing; `null` for a session just created in this call |
| `path(relativePath)` | Joins `relativePath` onto the session directory. Pure path math — does **not** create the directory or touch the filesystem |
| `toString()` | Returns `name` |

## `getStickyPort`

```ts
function getStickyPort(
	session: DevSession,
	options?: { name?: string },
): Effect.Effect<number, PlatformError | SessionStateError, FileSystem | Path>
```

Reads the port already stored under `options?.name ?? 'default'` in `session`'s `sess.json` (`{ ports: Record<string, number> }`), passes it to [`get-port`](https://github.com/sindresorhus/get-port) as a preference, and writes back whichever port that call actually returns, keyed by that same name. The ports remembered under every other name are passed to `get-port` as `exclude`, so resolving one name can never return a port another name is holding. A `sess.json` written before named ports existed has a top-level `port` instead of `ports` — still read once, as the seed for `default`, but never written again once the new shape lands. **Not cached** — every call re-reads and re-asks; call it once per name per run and reuse the returned number. Built on `SessionState.slot` internally, so a `sess.json` corrupted by something outside `devsess` surfaces here as `SessionStateError` too.

## `runManagedSubprocess`

```ts
function runManagedSubprocess(
	cmd: string,
	args: string[],
	opts?: { env?: Record<string, string> },
): Effect.Effect<ExitCode, PlatformError, ChildProcessSpawner | Scope.Scope>
```

Takes no `DevSession` — it only needs a scope to register cleanup against and something to spawn a process with. `ExitCode` (`effect/unstable/process`) is a branded `number`.

| Parameter | Type | |
| --- | --- | --- |
| `cmd` | `string` | The executable to run |
| `args` | `string[]` | Arguments passed to it |
| `opts.env` | `Record<string, string>` | Extra environment variables, merged over `process.env` — they add and override, they don't replace it |

Resolves to the child's exit code, once the child exits. stdio is `inherit` for stdin/stdout/stderr, so child output goes straight to your terminal. The child is registered against the ambient `Scope` — killed when it closes (script exit, throw, or Ctrl-C); a failed kill is logged, not thrown.

## `publishRunning`, `awaitRunning`

```ts
function publishRunning(
	data: unknown,
): Effect.Effect<void, PlatformError, DevSessions | FileSystem | Path | Scope.Scope>

function awaitRunning<T>(
	pkg: string,
): Effect.Effect<T, PlatformError, DevSessions | FileSystem | Path>
```

Neither takes a session — both pull the project root from `DevSessions` in context.

**`publishRunning(data)`** — writes `data` as JSON to `<DevSessions#dir>/.data/running.json`, atomically (temp file, then rename, so readers never see a partial write). Deletes that file when its scope closes, so a crashed or exited publisher leaves no stale signal behind.

**`awaitRunning<T>(pkg)`** — reads *another* package's running-signal file, not this project's own. `pkg` starting with `.` or `/` resolves as a path relative to `DevSessions#dir`; anything else is resolved as a package name via `require.resolve('<pkg>/package.json')`. Creates the sibling's `.data` directory if it doesn't exist yet (`fs.watch` would otherwise throw synchronously on a missing directory), then waits using `fs.watch` on it (no polling), plus one immediate read to catch the already-published case. **No timeout** — waits indefinitely.

Running signals are **not session-scoped** — they coordinate across sibling packages in a monorepo, keyed by project directory. See [Wiring services together](/recipes/wiring-services).

## `SessionState.slot`

```ts
namespace SessionState {
	function slot<T extends Schema.Top>(schema: T): {
		read: (session: DevSession) => Effect.Effect<T['Type'] | null, PlatformError, FileSystem | T['DecodingServices']>
		write: (session: DevSession, data: T['Type']) => Effect.Effect<void, PlatformError | SessionStateError, FileSystem | Path>
	}
}

class SessionStateError extends Data.TaggedError('SessionStateError')<{
	message: string
	cause?: unknown
}> {}
```

`devsess` doesn't re-export `Schema` — build `schema` from `effect` directly (`import { Schema } from 'effect'`).

* All slots for a session share one file, `<session>/sess.json`. `write` shallow-merges `data` into the existing JSON, so distinct slots (distinct schemas) coexist by key — but a key collision between two slots silently overwrites.
* `read` resolves `null`, not `Option` — both when the file is missing and when decoding fails.
* `write` creates the parent directory before writing, but the write itself is **not atomic** (no temp-file-then-rename, unlike the running-signal file). If the on-disk JSON is corrupt, `write` fails with `SessionStateError` (`_tag: 'SessionStateError'`) instead of merging into it — `read` is the one that degrades corrupt content to `null`, not `write`.
