Skip to content

Commit 1faf972

Browse files
sanchitrkclaude
andcommitted
refactor: resolve each plugin view against its hostDir, never cwd
- Give a project view the host directory its sessions' `hostDir` led to for anchoring and parsing, falling back to `hostCwd` only in the process view, which has no session; `filed` parses against the reference's own anchor - Name the CLI plugin commands' host anchor `hostDir` (the linked session's, or the directory the command runs in) instead of `cwd`, which is reserved for a sandbox's working directory - Link E2E sessions only through `hostDir`, leave their sandbox directory alone, and run the app from a neutral `hostCwd`; the artifacts are byte-identical, so settings reach a session through `hostDir` alone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7ae2070 commit 1faf972

8 files changed

Lines changed: 66 additions & 42 deletions

File tree

‎packages/codework/src/cli/cmd/handlers/plugin/add.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default Runtime.handler(
5555
// What goes in the file, which is not always what was typed: a relative path anchors to
5656
// the settings file being written, not to the directory the command ran in.
5757
const entry = yield* written(reference, {
58-
cwd: from,
58+
hostDir: from,
5959
file: target.path,
6060
root: target.root,
6161
});

‎packages/codework/src/cli/cmd/handlers/plugin/check.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export default Runtime.handler(
1818
Effect.fn("CLI.plugin.check")(function* ({ refresh, spec }) {
1919
const program = Effect.gen(function* () {
2020
const shared = yield* Cmd.spec;
21-
const { entries, cache } = yield* read(shared);
22-
const selected = yield* targeted(entries, spec);
21+
const { entries, cache, hostDir } = yield* read(shared);
22+
const selected = yield* targeted(entries, spec, hostDir);
2323
if (selected.length === 0 && Option.isSome(spec)) return;
2424

2525
let outdated = 0;

‎packages/codework/src/cli/cmd/handlers/plugin/entries.ts‎

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ const moduleOf = (entry: unknown): string | undefined => (typeof entry === "stri
3131

3232
export const read = Effect.fn("CLI.plugin.entries")(function* (shared: Shared) {
3333
const nodePath = yield* Path.Path;
34-
const cwd = nodePath.resolve(".");
34+
// No session links this command to a host directory, so the one it runs in is its anchor:
35+
// project settings are discovered from here, and a relative spec typed here resolves here.
36+
const hostDir = nodePath.resolve(".");
3537
const paths = yield* Global.resolve(Option.isNone(shared.home) ? {} : { home: shared.home.value });
36-
const root = yield* Settings.projectRoot(cwd, paths.home);
38+
const root = yield* Settings.projectRoot(hostDir, paths.home);
3739
const layers = {
3840
home: paths.home,
3941
...(Option.isNone(shared.userConfigDir) ? {} : { userConfigDir: shared.userConfigDir.value }),
@@ -53,15 +55,15 @@ export const read = Effect.fn("CLI.plugin.entries")(function* (shared: Shared) {
5355
const entries = new Map<string, Entry>();
5456
for (const view of views) {
5557
for (const [reference, one] of Settings.modules(view.declared)) {
56-
// Already anchored to the file that declared it, so `cwd` here only affects a reference
57-
// no settings file produced.
58-
const target = Option.getOrUndefined(yield* Plugin.parse(reference, cwd).pipe(Effect.option));
59-
const from = Plugin.anchor(one.file, cwd);
58+
// Already anchored to the file that declared it, so `hostDir` here only affects a
59+
// reference no settings file produced.
60+
const target = Option.getOrUndefined(yield* Plugin.parse(reference, hostDir).pipe(Effect.option));
61+
const from = Plugin.anchor(one.file, hostDir);
6062
const key = yield* identity(target, reference, from, paths.cache);
6163
entries.set(key, { written: moduleOf(one.written) ?? reference, reference, file: one.file, from, target });
6264
}
6365
}
64-
return { entries: [...entries.values()] as ReadonlyArray<Entry>, cache: paths.cache };
66+
return { entries: [...entries.values()] as ReadonlyArray<Entry>, cache: paths.cache, hostDir };
6567
});
6668

6769
/**
@@ -75,29 +77,30 @@ const identity = (target: Plugin.Target | undefined, reference: string, from: st
7577
};
7678

7779
/** What a reference names, whatever version it was written with: a package, a git spec, a file. */
78-
const source = (reference: string, cwd: string) =>
79-
Plugin.canonical(reference, cwd).pipe(Effect.orElseSucceed(() => reference));
80+
const source = (reference: string, hostDir: string) =>
81+
Plugin.canonical(reference, hostDir).pipe(Effect.orElseSucceed(() => reference));
8082

8183
/**
8284
* The entries a command acts on: all of them, or every one `spec` names.
8385
*
8486
* Matched by source identity, the way the `plugins` array reads -- `@acme/x` selects
85-
* `@acme/x@^1`, a relative path selects the file it resolves to from here, and a local package
87+
* `@acme/x@^1`, a relative path selects the file it resolves to from `hostDir`, and a local package
8688
* also answers to the name its `package.json` declares. A plugin ID is not a spelling of anything
8789
* in that array, so it matches nothing. Every match is kept: a spec both layers declare under
8890
* different registries is two artifacts, and both answer to it.
8991
*/
9092
export const targeted = Effect.fn("CLI.plugin.targeted")(function* (
9193
entries: ReadonlyArray<Entry>,
9294
spec: Option.Option<string>,
95+
/** The host directory the command resolves from, which a relative spec is typed against. */
96+
hostDir: string,
9397
) {
9498
if (Option.isNone(spec)) return entries;
95-
const cwd = (yield* Path.Path).resolve(".");
96-
const wanted = yield* source(spec.value, cwd);
99+
const wanted = yield* source(spec.value, hostDir);
97100
const selected: Entry[] = [];
98101
for (const entry of entries) {
99102
const named =
100-
(yield* source(entry.reference, cwd)) === wanted ||
103+
(yield* source(entry.reference, hostDir)) === wanted ||
101104
(entry.target?.kind === "local" && (yield* Plugin.localName(entry.target.path)) === wanted);
102105
if (named) selected.push(entry);
103106
}

‎packages/codework/src/cli/cmd/handlers/plugin/settings.ts‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const identify = Effect.fn("CLI.plugin.identify")(function* (
113113
/**
114114
* What a local reference should be *written* as, which is rarely what was typed.
115115
*
116-
* A relative path on the command line is relative to `cwd`; in a settings file it is relative to
116+
* A relative path on the command line is relative to the command's host directory; in a settings file it is relative to
117117
* that file's own directory. Those differ the moment the command runs anywhere but the directory
118118
* holding the file, and writing the string verbatim produces a silently wrong entry:
119119
*
@@ -133,10 +133,10 @@ export const identify = Effect.fn("CLI.plugin.identify")(function* (
133133
*/
134134
export const written = Effect.fn("CLI.plugin.written")(function* (
135135
reference: string,
136-
input: { readonly cwd: string; readonly file: string; readonly root: string | undefined },
136+
input: { readonly hostDir: string; readonly file: string; readonly root: string | undefined },
137137
) {
138138
const nodePath = yield* Path.Path;
139-
const target = yield* Plugin.parse(reference, input.cwd).pipe(Effect.option);
139+
const target = yield* Plugin.parse(reference, input.hostDir).pipe(Effect.option);
140140
if (Option.isNone(target) || target.value.kind !== "local") return reference;
141141

142142
const absolute = target.value.path;
@@ -259,14 +259,15 @@ export const resolveTarget = Effect.fn("CLI.plugin.resolveTarget")(function* (
259259
) {
260260
const fs = yield* FileSystem.FileSystem;
261261
const nodePath = yield* Path.Path;
262-
const cwd = from ?? nodePath.resolve(".");
262+
// The linked session's host directory, or -- with none -- the one the command runs in.
263+
const hostDir = from ?? nodePath.resolve(".");
263264
const home = yield* Global.resolve(Option.isNone(shared.home) ? {} : { home: shared.home.value });
264-
const root = global ? undefined : yield* Settings.projectRoot(cwd, home.home);
265+
const root = global ? undefined : yield* Settings.projectRoot(hostDir, home.home);
265266
// `paths` is ordered lowest priority first: user-wide, then project, then an explicit directory.
266267
const groups = Settings.paths({
267268
home: home.home,
268269
...(root === undefined ? {} : { root }),
269-
from: cwd,
270+
from: hostDir,
270271
...(Option.isNone(shared.userConfigDir) ? {} : { custom: shared.userConfigDir.value }),
271272
});
272273
const chosen = Option.isSome(shared.userConfigDir) ? groups.length - 1 : global ? 0 : 1;
@@ -281,10 +282,11 @@ export const resolveTarget = Effect.fn("CLI.plugin.resolveTarget")(function* (
281282
...(root === undefined ? {} : { root }),
282283
} satisfies Target;
283284
}
284-
const marker = nodePath.join(cwd, ".codework");
285+
const marker = nodePath.join(hostDir, ".codework");
285286
// `created` is reported either way: with `create` unset the marker is named, not made, so a
286287
// caller can say what its write is about to establish without establishing it early.
287-
if (!create) return { path: nodePath.join(marker, "settings.jsonc"), created: marker, root: cwd } satisfies Target;
288+
if (!create)
289+
return { path: nodePath.join(marker, "settings.jsonc"), created: marker, root: hostDir } satisfies Target;
288290
yield* fs.makeDirectory(marker, { recursive: true });
289-
return { path: nodePath.join(marker, "settings.jsonc"), created: marker, root: cwd } satisfies Target;
291+
return { path: nodePath.join(marker, "settings.jsonc"), created: marker, root: hostDir } satisfies Target;
290292
});

‎packages/codework/src/cli/cmd/handlers/plugin/update.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export default Runtime.handler(
1818
Effect.fn("CLI.plugin.update")(function* ({ spec }) {
1919
const program = Effect.gen(function* () {
2020
const shared = yield* Cmd.spec;
21-
const { entries, cache } = yield* read(shared);
22-
const selected = yield* targeted(entries, spec);
21+
const { entries, cache, hostDir } = yield* read(shared);
22+
const selected = yield* targeted(entries, spec, hostDir);
2323
if (selected.length === 0 && Option.isSome(spec)) return;
2424

2525
let updated = 0;

‎packages/codework/test/plugin.cmd.test.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,19 +101,22 @@ const LOCAL_PLUGIN = [
101101
].join("\n");
102102

103103
/**
104-
* Exchanges in one real runtime process, resolve-only, against what the CLI filed -- one per
105-
* directory, in order, each directory keeping its own session. The markers each system prompt
106-
* ends with are the plugins that exchange actually ran.
104+
* Exchanges in one real runtime process, resolve-only, against what the CLI filed -- one per host
105+
* directory, in order, each keeping its own session. The markers each system prompt ends with are
106+
* the plugins that exchange actually ran.
107+
*
108+
* The app runs from its home (`hostCwd`), nowhere near a project: settings reach a session only
109+
* through the `hostDir` it is linked to. Its sandbox working directory is left to the session.
107110
*/
108-
const exchanges = async (home: string, directories: ReadonlyArray<string>) => {
111+
const exchanges = async (home: string, hostDirs: ReadonlyArray<string>) => {
109112
const prompts: string[] = [];
110113
const open = immediateOpen();
111114
await Effect.runPromise(
112115
Effect.gen(function* () {
113116
const sessions = new Map<string, Effect.Success<ReturnType<typeof Session.create>>>();
114-
for (const directory of directories) {
115-
const session = sessions.get(directory) ?? (yield* Session.create({ directory, hostDir: directory }));
116-
sessions.set(directory, session);
117+
for (const hostDir of hostDirs) {
118+
const session = sessions.get(hostDir) ?? (yield* Session.create({ hostDir }));
119+
sessions.set(hostDir, session);
117120
yield* session.prompt({ text: "go", delivery: "followUp" });
118121
yield* session.resume();
119122
yield* session.wait();
@@ -122,7 +125,7 @@ const exchanges = async (home: string, directories: ReadonlyArray<string>) => {
122125
Effect.provide(
123126
Harness.layer({
124127
home,
125-
hostCwd: directories[0] ?? home,
128+
hostCwd: home,
126129
database: ":memory:",
127130
llm: (request, signal) => {
128131
prompts.push(request.context.systemPrompt ?? "");
@@ -135,7 +138,7 @@ const exchanges = async (home: string, directories: ReadonlyArray<string>) => {
135138
Effect.orDie,
136139
),
137140
);
138-
expect(prompts).toHaveLength(directories.length);
141+
expect(prompts).toHaveLength(hostDirs.length);
139142
return prompts.map((prompt) => prompt.match(/\[(?:owner:[^\]]*|git:[^\]]*|local)\]/g) ?? []);
140143
};
141144

‎packages/harness/src/effect/harness.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,14 @@ export const layer = (options: Options = {}) =>
9898
const declaredIn = (settings: SettingsInfo): ReadonlyMap<string, string> =>
9999
new Map(Array.from(Settings.modules(settings.declared), ([reference, one]) => [reference, one.file]));
100100

101+
// The process view has no session, so no host directory of its own: the app's working
102+
// directory stands in for a reference nothing anchored -- an embedder's own list.
101103
const catalogOptions = { builtins, cache: paths.cache, hostDir: hostCwd };
104+
/**
105+
* The host directory a view resolves against: a project view's is the root its sessions'
106+
* `hostDir` led to; only the process view falls back to `hostCwd`.
107+
*/
108+
const hostDirOf = (root: string | undefined) => root ?? hostCwd;
102109

103110
/*
104111
* What the store already holds for a reference, and nothing else.
@@ -110,7 +117,7 @@ export const layer = (options: Options = {}) =>
110117
*/
111118
type Here = Option.Option<{ readonly generation?: number }>;
112119
const filed = (reference: string, from: string): Effect.Effect<Here> =>
113-
PluginSource.parse(reference, hostCwd).pipe(
120+
PluginSource.parse(reference, from).pipe(
114121
Effect.flatMap((target): Effect.Effect<Here, unknown> =>
115122
target.kind === "local"
116123
? // On disk by definition, so it can be loaded now -- but never filed, so it
@@ -143,8 +150,13 @@ export const layer = (options: Options = {}) =>
143150
const pool = yield* Ref.make<Pool>(
144151
yield* load(references(config), { ...resolveOnly, declared: declaredIn(config) }),
145152
);
146-
const followStore = (refs: ReadonlyArray<PluginRef>, current: Pool, settings: SettingsInfo) =>
147-
follow(refs, current, { ...resolveOnly, declared: declaredIn(settings) }, filed);
153+
const followStore = (
154+
refs: ReadonlyArray<PluginRef>,
155+
current: Pool,
156+
settings: SettingsInfo,
157+
root: string | undefined,
158+
) =>
159+
follow(refs, current, { ...resolveOnly, hostDir: hostDirOf(root), declared: declaredIn(settings) }, filed);
148160

149161
/*
150162
* A reload re-imports everything, including the local plugins nothing else can notice
@@ -183,7 +195,7 @@ export const layer = (options: Options = {}) =>
183195
for (const origin of loaded.origins.values()) {
184196
if (
185197
configured.includes(origin.reference) ||
186-
Option.isSome(yield* filed(origin.reference, anchor(origin.file, hostCwd)))
198+
Option.isSome(yield* filed(origin.reference, anchor(origin.file, hostDirOf(root))))
187199
) {
188200
retained.push(origin);
189201
continue;
@@ -203,6 +215,7 @@ export const layer = (options: Options = {}) =>
203215
for (const [reference, file] of declaredIn(current)) known.set(reference, file);
204216
return yield* load(accumulated, {
205217
...resolveOnly,
218+
hostDir: hostDirOf(root),
206219
declared: known,
207220
reload: reloads,
208221
});

‎packages/harness/src/state/state.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ export const layer = (
205205
refs: ReadonlyArray<PluginRef>,
206206
current: Pool,
207207
settings: Info,
208+
/** The view's project root, whose host directory the view resolves against. */
209+
root: string | undefined,
208210
) => Effect.Effect<Option.Option<Pool>, { readonly message: string }>,
209211
/**
210212
* A full load pass, told to re-import even where nothing looks different. Resolve-only, like
@@ -371,9 +373,10 @@ export const layer = (
371373
const loaded = yield* Effect.gen(function* () {
372374
// Re-read inside the permit: whoever held it may have just done this exact
373375
// work, and adopting their result is the point.
374-
const target = yield* poolOf(yield* viewOf(hostDir));
376+
const root = yield* viewOf(hostDir);
377+
const target = yield* poolOf(root);
375378
const current = yield* Ref.get(target);
376-
const moved = yield* follow(refs, current, loadedSettings).pipe(
379+
const moved = yield* follow(refs, current, loadedSettings, root).pipe(
377380
Effect.tapError((cause) => publishFailed(cause, sessionId)),
378381
);
379382
if (Option.isNone(moved)) return current;

0 commit comments

Comments
 (0)