Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/effect-filesystem-phase2-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Keep `agent-bundle validate` (the `AB60xx` portable / Claude / Cursor plugin diagnostics), `mcp list` / `mcp invoke` / `mcp run`, `hooks list`, `eval`, and the post-build artifact readers (`validateArtifact`, the pack inventory) behaving exactly as before while their file reads, copies, and temporary directories move onto the shared platform layer: the same diagnostic codes and messages, the same `ENOENT` / `ENOTDIR` / `ELOOP` errors at the same places, byte-identical built artifacts. Two guarantees are now unconditional: `mcp run` stops forwarding SIGINT/SIGTERM to the server the moment the server exits, however it exits, and `mcp invoke` removes its per-connection plugin-data directory even when connecting fails. (#540)
70 changes: 54 additions & 16 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,20 @@ the first-party CLI's user-facing text — see
`dev/mcp-session/mcp-session-service.ts`): a scoped temp is removed when
the scope closes, which is too early there.
- File handles whose use is bounded by one program: scoped `open`.
- Text reads: `readFileString(path)` / `readFileBytes(path)` from
`src/effect/platform.ts`, not `fs.readFileString`. The service method
decodes through `TextDecoder`, which drops a leading UTF-8 BOM; Node's
`readFile(path, 'utf8')` keeps it as U+FEFF, and the migrated sites parse
JSON and digest what they read, so the bytes must decode as before.
`isPlatformErrno(error, ...codes)` is the `isErrno` check for a failure
that may still be wrapped in a `PlatformError`, for programs that branch
on `ENOENT` / `ENOTDIR` / `ELOOP` the way their `try`/`catch` did.
- Mixed modules: when one function pairs a link-identity check (`lstat`,
`realpath`, `Dirent.isDirectory()`, `O_NOFOLLOW`, `dev`/`ino`) with
ordinary reads, the ordinary reads move to the service and the identity
check stays on `node:fs` with a one-line comment at the site. Do not
replace `lstat` with `stat` to finish the migration: `stat` follows the
link, and every one of those checks exists to refuse it.
- Layer wiring: one composition root per process. The scaffolder provides
`NodeServices.layer` immediately before its boundary's `runPromise`
(`src/scaffold-cli.ts`, loaded by `runCli` only once a scaffold is
Expand Down Expand Up @@ -425,6 +439,16 @@ the first-party CLI's user-facing text — see
installer body itself is Effect-native.
- `events/ipc.ts` inode locks (`open` with `wx` + `stat` identity + Linux
start time).
- `eval/workspace-diff.ts`: hashes through an `O_NOFOLLOW` descriptor and
compares its `dev`/`ino`/`nlink` with the discovering `lstat`; `opendir`
for `Dirent` kinds. `build/validate-artifact.ts` `snapshotManifest`
(`lstat` / read / `lstat` `dev`/`ino` identity), the `lstat` rows in
`validate-artifact-modules.ts`, `pack-inventory.ts`, `eval/artifact.ts`,
`eval/fixtures.ts`, `eval/graders.ts`, and the `Dirent`-typed listings in
`eval/codex-plugins.ts` and `host-contracts/*` `symlinkDiagnostics`
(a symlinked directory is not a directory to them). `declaration-diagnostics.ts`'s
synchronous `existsSync` probe beside `createRequire`'s synchronous
resolution.
- Synchronous SQLite setup (`rsc-runtime/src/state/sqlite.ts`).
- `dev/watcher.ts`: chokidar stays. `FileSystem.watch` is a thin `fs.watch`
with create/update/remove only — no `ignored` callbacks, readiness, or the
Expand Down Expand Up @@ -460,22 +484,36 @@ split. `undici` is not pulled into the bundle.
`packages/agent-bundle/src/effect/platform.ts` owns the framework's platform
layer: `platformLayer` (the `NodeServices` union composed from
`@effect/platform-node-shared`), `withTempDirectory`, `ensuringRemoved`,
`unwrapPlatformError`, and `runWithPlatform`, which provides the layer and
`unwrapPlatformError`, `isPlatformErrno`, `readFileString` /
`readFileBytes`, and `runWithPlatform`, which provides the layer and
unwraps `PlatformError` before handing off to `boundary.ts`'s `runPromise`.
It is the only module that imports `effect/PlatformError`: `boundary.ts` is
bundled into every emitted hook wrapper, and the error class would drag
`Data.TaggedError` into each one (measured: +12 kB per hook). Phase-1
callers are the throwaway artifact in `api.ts` (`listMcp` / `invokeMcp` /
`runMcp` / `listHooks` / `simulateHook` without `artifact`) and the Codex
validator's schema-generation directory, both through `withTempDirectory`,
and `routes/typegen.ts`'s `writeRouteTypesProgram` (the atomic
`routes.d.ts` publish, through `ensuringRemoved`; `writeRouteTypes` keeps
its Promise signature via `runWithPlatform`). The sibling `routes/graph.ts`
reads stay raw: `compileRouteGraph` is the compiler/cold-start discovery
path, and lifting only its two async reads would add an Effect runtime per
compile for nothing. Emitted artifacts, hook wrappers, and compiler hot
paths never import this module; the dev server picks it up in phase 2
through `makeScopedEffectRuntime(platformLayer)`.
It is the only module that imports `effect/PlatformError` at run time
(`import type` is erased and fine): `boundary.ts` is bundled into every
emitted hook wrapper, and the error class would drag `Data.TaggedError`
into each one (measured: +12 kB per hook). Phase-1 callers are the
throwaway artifact in `api.ts` (`listMcp` / `invokeMcp` / `runMcp` /
`listHooks` / `simulateHook` without `artifact`) and the Codex validator's
schema-generation directory, both through `withTempDirectory`, and
`routes/typegen.ts`'s `writeRouteTypesProgram` (the atomic `routes.d.ts`
publish, through `ensuringRemoved`; `writeRouteTypes` keeps its Promise
signature via `runWithPlatform`). Phase 2 (ordinary-I/O modules, behind
command dispatch, 2026-09-03) moved the ordinary reads, copies, and temp
directories of `host-contracts/{claude,cursor,portable}-plugin-validation.ts`
and `native-codex-contract.ts`, `services/{hook-service,mcp-service,mcp-run}.ts`
(the MCP client's plugin-data directory is a `withTempDirectory` bracket;
`mcp-run`'s SIGINT/SIGTERM forwarding is a scoped `acquireRelease`), the
`eval/*` harness readers, `fixtures.ts` materialization and the Codex trial
home, and the post-build readers `build/validate-artifact*.ts`,
`pack-inventory.ts` — each through `runWithPlatform` at its existing
Promise signature, with the link-identity checks kept raw per the
carve-outs above. The sibling `routes/graph.ts` reads stay raw:
`compileRouteGraph` is the compiler/cold-start discovery path, and lifting
only its two async reads would add an Effect runtime per compile for
nothing. Emitted artifacts, hook wrappers, compiler hot paths, and the
modules `cli.ts` loads eagerly never import this module (`cli.test.ts`
fails if `--version` / `--help` resolve an `effect` module); the dev server
picks it up in phase 2's second PR through
`makeScopedEffectRuntime(platformLayer)`.

### Terminal and Stdio: user-facing CLI text

Expand Down Expand Up @@ -650,7 +688,7 @@ wire contracts](#effect-schema-wire-contracts-schema-projections).
| Module | Adopted in | Re-verify |
| --- | --- | --- |
| `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) and route editor (#105 phase 2) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships |
| `@effect/platform-node` (`NodeServices.layer`, `create-agent-bundle`) and `@effect/platform-node-shared` (`agent-bundle`'s `platformLayer`); `FileSystem` / `Path` services live in `effect` | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder and the `agent-bundle` temp directories in `api.ts` / the Codex validator (phase 1); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list and the consumer-footprint reason for the split | re-pin bumps both in lockstep with `effect`; re-check whether `@effect/platform-node` still forces a `redis` peer (if it stops, `agent-bundle` can move to `NodeServices.layer`); re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract |
| `@effect/platform-node` (`NodeServices.layer`, `create-agent-bundle`) and `@effect/platform-node-shared` (`agent-bundle`'s `platformLayer`); `FileSystem` / `Path` services live in `effect` | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder and the `agent-bundle` temp directories in `api.ts` / the Codex validator (phase 1); host-contracts validators, `services/*`, `eval/*`, and the post-build readers (phase 2, ordinary-I/O modules, 2026-09-03); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list and the consumer-footprint reason for the split | re-pin bumps both in lockstep with `effect`; re-check whether `@effect/platform-node` still forces a `redis` peer (if it stops, `agent-bundle` can move to `NodeServices.layer`); re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract |
| `@effect/platform-node-shared` (`NodeTerminal` / `NodeStdio`) + `effect/Terminal`, `effect/Stdio` | first-party CLI command output, diagnostics, and machine output (`src/cli.ts`, `src/effect/terminal.ts`, `src/effect/cli-runtime.ts`), loaded lazily on the first command write (2026-09-03); Commander's help/version/argv-error text and the scaffolder's `--help` / flag-error text stay on synchronous process writes for the cold-start budget | re-pin re-checks `Terminal.display` stays stdout-only, `readLine` EOF → `QuitError`, the `Stdio` sink contract, and re-measures `agent-bundle --version` startup against the recorded ≈60 ms (`cli.test.ts` fails the build if the trivial invocations resolve an `effect` module) |
| `Schema` / `SchemaAST` / `SchemaParser` projections (`toType` / `toEncoded`) for wire contracts | **declined** (2026-09-01) | revisit at Effect GA or on the first encoded/decoded-divergent wire contract; re-pin re-checks the projections API and the `onExcessProperty` parse-option default |

Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/build/declaration-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const typeScriptCli = (projectRoot: string): string | undefined => {
return undefined;
}
const cli = join(dirname(manifest), 'lib', 'tsc.js');
// Synchronous probe beside `createRequire`'s synchronous resolution; stays raw.
return existsSync(cli) ? cli : undefined;
};

Expand Down
10 changes: 6 additions & 4 deletions packages/agent-bundle/src/build/pack-inventory.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createHash } from 'node:crypto';
import { lstat, readFile } from 'node:fs/promises';
import { lstat } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';

import type { NormalizedPlugin } from '../core/types.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { deepFreeze } from '../core/freeze.ts';
import { readFileBytes, readFileString, runWithPlatform } from '../effect/platform.ts';
import { installSurfaceRequirements } from '../install/surface.ts';
import { artifactManifestName } from './emit.ts';
import { parseArtifactManifest } from './manifest.ts';
Expand Down Expand Up @@ -76,6 +77,7 @@ export const packOutputFromJson = (stdout: string, packageName?: string): PackOu
const toPosixRelative = (root: string, path: string): string =>
relative(resolve(root), resolve(path)).replaceAll('\\', '/');

/** Stays on `lstat`: a dangling symlink at a host manifest path still counts as present. */
const exists = async (path: string): Promise<boolean> => {
try {
await lstat(path);
Expand All @@ -87,7 +89,7 @@ const exists = async (path: string): Promise<boolean> => {
};

const jsonRecord = async (path: string): Promise<Readonly<Record<string, unknown>>> => {
const value: unknown = JSON.parse(await readFile(path, 'utf8'));
const value: unknown = JSON.parse(await runWithPlatform(readFileString(path)));
if (!isRecord(value)) throw new TypeError(`Expected a JSON object at ${JSON.stringify(path)}.`);
return value;
};
Expand Down Expand Up @@ -140,7 +142,7 @@ export const packInventoryDiagnostics = async (options: {
const artifactPrefix = toPosixRelative(projectRoot, artifactRoot);
const packagePrefix = toPosixRelative(projectRoot, options.packageBuild.outputRoot);
const manifestPath = join(artifactRoot, artifactManifestName);
const manifest = parseArtifactManifest(await readFile(manifestPath, 'utf8'));
const manifest = parseArtifactManifest(await runWithPlatform(readFileString(manifestPath)));
const packageDocument = await jsonRecord(join(projectRoot, 'package.json'));
const packed = new Set(options.packOutput.files.map((file) => file.path.replace(/^\.\//u, '')));
const expected = new Set<string>([
Expand All @@ -164,7 +166,7 @@ export const packInventoryDiagnostics = async (options: {

const stale: string[] = [];
for (const file of manifest.files) {
const bytes = await readFile(join(artifactRoot, file.path));
const bytes = await runWithPlatform(readFileBytes(join(artifactRoot, file.path)));
if (createHash('sha256').update(bytes).digest('hex') !== file.sha256) stale.push(`${artifactPrefix}/${file.path}`);
}
if (stale.length > 0) {
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-bundle/src/build/validate-artifact-hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import type { TargetRegistry } from '../adapters/registry.ts';
Expand All @@ -8,6 +7,7 @@ import {
readTargetNativeHookCommands,
} from '../adapters/hook-contract.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { readFileString, runWithPlatform } from '../effect/platform.ts';
import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts';
import { matchesManifestFile, pathInTargetOutputLayout, targetArtifactPath } from './artifact-layout.ts';
import {
Expand All @@ -21,7 +21,7 @@ import type { ArtifactManifest } from './manifest.ts';

const readArtifactHookIndex = async (artifactRoot: string): Promise<ArtifactHookIndex | undefined> => {
try {
return parseArtifactHookIndex(await readFile(resolve(artifactRoot, artifactHookIndexName), 'utf8'));
return parseArtifactHookIndex(await runWithPlatform(readFileString(resolve(artifactRoot, artifactHookIndexName))));
} catch {
return undefined;
}
Expand Down Expand Up @@ -108,7 +108,7 @@ export const validateHookCoherence = async (options: {
}
let document: unknown;
try {
document = JSON.parse(await readFile(resolve(options.artifactRoot, manifestPath), 'utf8'));
document = JSON.parse(await runWithPlatform(readFileString(resolve(options.artifactRoot, manifestPath))));
} catch {
diagnostics.push(diagnostic(
'AB6018',
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-bundle/src/build/validate-artifact-mcp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { readFile } from 'node:fs/promises';
import { dirname, posix, resolve, win32 } from 'node:path';

import type { TargetRegistry } from '../adapters/registry.ts';
Expand All @@ -8,6 +7,7 @@ import { classifyMcpArtifactArgument } from '../services/mcp-artifact-reference.
import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts';
import { readTargetMcpServers } from '../services/mcp-runtime.ts';
import { artifactDiagnostic as diagnostic, artifactDiagnosticRecoveries } from './artifact-diagnostics.ts';
import { readFileString, runWithPlatform } from '../effect/platform.ts';
import { matchesManifestFile, pathInTargetOutputLayout, targetArtifactPath } from './artifact-layout.ts';
import type { ValidatedArtifactMcpServerEvidence } from './artifact-validation-types.ts';
import type { ArtifactFile, ManifestFile } from './emit.ts';
Expand Down Expand Up @@ -140,7 +140,7 @@ export const validateMcpCoherence = async (options: {
if (manifestFile !== undefined) {
let document: unknown;
try {
document = parseJsonWithoutDuplicateKeys(await readFile(resolve(artifactRoot, manifestPath), 'utf8'));
document = parseJsonWithoutDuplicateKeys(await runWithPlatform(readFileString(resolve(artifactRoot, manifestPath))));
} catch {
diagnostics.push(diagnostic(
'AB6017',
Expand Down Expand Up @@ -280,7 +280,7 @@ export const validateMcpCoherence = async (options: {
const mainPath = path.slice(0, -'-flight.mjs'.length) + '.mjs';
const mainReferences = referenceCounts.get(mainPath);
if (mainReferences?.length === 1) {
const mainSource = await readFile(resolve(artifactRoot, mainPath), 'utf8');
const mainSource = await runWithPlatform(readFileString(resolve(artifactRoot, mainPath)));
if (mainSource.includes(`./${posix.basename(path)}`)) continue;
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-bundle/src/build/validate-artifact-modules.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { lstat, readFile, realpath } from 'node:fs/promises';
import { lstat, realpath } from 'node:fs/promises';
import { isBuiltin } from 'node:module';
import { isAbsolute, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

import { sha256Hex } from '../core/digest.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { readFileBytes, runWithPlatform } from '../effect/platform.ts';
import { artifactDiagnostic as diagnostic, artifactDiagnosticRecoveries } from './artifact-diagnostics.ts';
import type { ArtifactFile } from './emit.ts';
import { readModuleImports, rememberedModuleImports, type ModuleSyntaxCheck } from './module-imports.ts';
Expand Down Expand Up @@ -128,7 +129,7 @@ export const validateJavaScriptModules = async (options: {
const check = options.bundledPaths?.has(path) === true ? options.bundleSyntaxCheck ?? 'lexed' : 'parsed';
let bytes: Buffer;
try {
bytes = await readFile(resolve(artifactRoot, path));
bytes = await runWithPlatform(readFileBytes(resolve(artifactRoot, path)));
} catch {
diagnostics.push(graphDiagnostic(path, 'cannot be read.'));
visiting.delete(path);
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/src/build/validate-artifact-skills.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import type { TargetRegistry } from '../adapters/registry.ts';
import { parseSkillMarkdown, referencedResources } from '../config/skill-references.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { readFileString, runWithPlatform } from '../effect/platform.ts';
import { validateAgentSkillsFrontmatter } from '../schemas/agent-skills/contract.ts';
import {
validateClaudeSkillFrontmatter,
Expand Down Expand Up @@ -112,7 +112,7 @@ export const validateEmittedSkills = async (options: {
for (const skill of skills) {
let markdown: string;
try {
markdown = await readFile(resolve(options.artifactRoot, skill.path), 'utf8');
markdown = await runWithPlatform(readFileString(resolve(options.artifactRoot, skill.path)));
} catch {
diagnostics.push(diagnostic(
'AB6015',
Expand Down
Loading
Loading