Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b58a138
feat(plugin): add `codework plugin add|remove` and declared plugin or…
sanchitrk Sep 17, 2026
62401fd
Merge branch 'dev' into feat-plugin-install
sanchitrk Sep 18, 2026
4585c70
feat(harness): discover settings from the session's host directory, n…
sanchitrk Sep 19, 2026
8cd0de7
feat(harness): install plugins through npm's own libraries instead of…
sanchitrk Sep 19, 2026
2072f45
feat(harness): give plugins a content-addressed store with immutable …
sanchitrk Sep 19, 2026
626f732
feat(harness): split plugin loading from plugin configuration
sanchitrk Sep 19, 2026
7cbb91d
feat(harness): let a session follow the store at each exchange boundary
sanchitrk Sep 19, 2026
d0dbf59
feat(cli): add plugin install, list, check and update
sanchitrk Sep 19, 2026
21bf41f
feat(harness): re-import plugins on demand with plugin reload
sanchitrk Sep 19, 2026
630ce95
feat(harness): keep the file that declared each plugin entry
sanchitrk Sep 19, 2026
e3b71ed
fix(harness): never install at boot, and name the file behind a plugi…
sanchitrk Sep 19, 2026
75c7985
feat(cli): link a session to the host directory its settings come from
sanchitrk Sep 19, 2026
a996367
feat(cli): add plugins to the project a session is linked to
sanchitrk Sep 19, 2026
377885c
fix(harness): read the .npmrc chain where the person is, not where th…
sanchitrk Sep 20, 2026
855bb43
test(harness): install against a registry and a git remote on this ma…
sanchitrk Sep 20, 2026
6a3d7c2
fix(cli): anchor a plugin reference to the session's project, not the…
sanchitrk Sep 20, 2026
a0384e0
fix(harness): refuse a relative directory where a plugin reference or…
sanchitrk Sep 20, 2026
e9e7172
fix(harness): refuse a relative host directory instead of resolving i…
sanchitrk Sep 20, 2026
f678a99
fix: make plugin manager updates atomic
sanchitrk Sep 20, 2026
33f135a
fix: address plugin manager review feedback
sanchitrk Sep 20, 2026
2524618
fix(harness): address plugin manager review issues
sanchitrk Sep 20, 2026
8c64e31
fix(harness): resolve plugin store entries under the registry their d…
sanchitrk Sep 20, 2026
e886eaa
fix(harness): drop retained plugin origins that no longer resolve
sanchitrk Sep 20, 2026
c949919
feat(harness): publish plugin.updated lifecycle events
sanchitrk Sep 20, 2026
e345ead
feat(harness): use session ID when available for plugin event updated
sanchitrk Sep 20, 2026
b8e8b07
tweaks
sanchitrk Sep 22, 2026
f530665
fix(harness): scope settings discovery to sessions; boot reads the us…
sanchitrk Sep 22, 2026
f1583cc
test: improve test coverage
sanchitrk Sep 22, 2026
52124ca
fix(harness): simplify absolute path check - regex checks are complic…
sanchitrk Sep 22, 2026
e3c8f2a
tweaks
sanchitrk Sep 22, 2026
9160d66
feat(plugin): add @codeworksh/plugin, the third-party plugin SDK
sanchitrk Sep 22, 2026
b74afbd
fix(harness): bind the sandbox barrel by name so the built drivers work
sanchitrk Sep 22, 2026
2a2917e
fix(harness): share effect and the plugin SDK with an installed plugin
sanchitrk Sep 22, 2026
294db88
fix(harness): call pacote through its default export so check and upd…
sanchitrk Sep 22, 2026
746500d
feat(harness): report a plugin that ships typescript as plugin-not-co…
sanchitrk Sep 22, 2026
7f970bf
feat(cli): add plugin check --refresh
sanchitrk Sep 22, 2026
d58036a
fix(scripts): drop the development condition from published manifests
sanchitrk Sep 22, 2026
ed63187
test(harness): cover plugins installed from git, end to end
sanchitrk Sep 22, 2026
69ca318
fix(cli): describe hostDir as an anchor, not as where settings come from
sanchitrk Sep 22, 2026
548ddea
fix(cli): name .codework in the link help so the directory is recogni…
sanchitrk Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(harness): split plugin loading from plugin configuration
- Separate the two passes by cost: loading a module means a store lookup and an ESM import, while
  configuring one is pure data over what is already in memory
- Produce a pool of loaded modules keyed by ID, carrying every string each one answers to, so a later
  entry can address a module by name, spec or path
- Run the config pass at every exchange, so an options edit, an enabled flip or a reordering takes effect
  on the next turn with nothing re-imported
- Hold the pool behind a mutable cell, read once at the top of a snapshot, so a future reload lands
  between exchanges and never inside one
- Report an entry naming a module the pool does not hold, instead of reaching for the network from an
  exchange
- Seed built-ins into the pool at construction, so disabling one goes through the same path as disabling
  anything else
- Keep the combined helper as the composition of the two passes, for callers with no reason to hold a pool

Project plugins now follow the session's host directory rather than the process's, which is only visible
once both changes are in: a session that was never placed reads no project settings, so entries from a
project file are loaded and then selected by nobody.
  • Loading branch information
sanchitrk committed Sep 19, 2026
commit 626f732a556cd6a6df6f23dd07660307ca09fae5
35 changes: 23 additions & 12 deletions packages/harness/src/effect/harness.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Effect, Layer } from "effect";
import { Effect, Layer, Ref } from "effect";
import { Context } from "../context/context.ts";
import { Control } from "../control.ts";
import { Database } from "../db/db.ts";
import { Event } from "../event/event.ts";
import { Global } from "../global.ts";
import { EventRegistry } from "../event/registry.ts";
import { prepare, type PluginRef } from "../plugin/catalog.ts";
import { load, type PluginRef, type Pool } from "../plugin/catalog.ts";
import { builtins } from "../plugin/builtin.ts";
import { RunnerExecute } from "../runner/execute.ts";
import { LLM } from "../runner/llm.ts";
Expand All @@ -18,6 +18,7 @@ import { SandboxDriverLoader } from "../sandbox/loader.ts";
import { SandboxDriverRegistry } from "../sandbox/registry.ts";
import { SessionLive } from "../session/live.ts";
import { SessionRuntime } from "../session/runtime.ts";
import type { Info as SettingsInfo } from "../settings/schema.ts";
import { Settings } from "../settings/settings.ts";
import { State } from "../state/state.ts";

Expand Down Expand Up @@ -60,17 +61,27 @@ export const layer = (options: Options = {}) =>
// root available -- and the right one: it is what decides which plugins this process
// loads at all. Which of them a given session *runs* is the per-session question.
const config = yield* Settings.load({ ...settingsOptions, home: paths.home, hostDir: hostCwd });
// Settings entries extend the built-in selection rather than standing in for it, so
// naming a plugin cannot silently drop Bash or the default prompt. A built-in is turned
// off by name, with a `{ "plugin": "codework.tool.bash", "enabled": false }` entry.
const selection = yield* prepare(options.plugins ?? [...builtins, ...config.plugins], {
builtins,
cache: paths.cache,
hostDir: hostCwd,
});
/*
* Which entries this process configures from, per exchange.
*
* Settings entries extend the built-in selection rather than standing in for it, so
* naming a plugin cannot silently drop Bash or the default prompt. A built-in is turned
* off by name, with a `{ "plugin": "codework.tool.bash", "enabled": false }` entry.
*
* An embedder that supplied its own list keeps it verbatim: it asked for exactly this
* selection, and the files must not add to it behind its back.
*/
const references = (settings: SettingsInfo): ReadonlyArray<PluginRef> =>
options.plugins ?? [...builtins, ...settings.plugins];

// The load pass, once, at boot: a store lookup and an ESM import per module. Every
// later exchange runs only the config pass over what this produced.
const pool = yield* Ref.make<Pool>(
yield* load(references(config), { builtins, cache: paths.cache, hostDir: hostCwd }),
);
// Flattened before anything can publish: a plugin event type that collides
// or is not namespaced is a boot failure, not a surprise at first publish.
const definitions = yield* EventRegistry.flatten(selection.map((entry) => entry.plugin));
const definitions = yield* EventRegistry.flatten([...(yield* Ref.get(pool)).plugins.values()]);
const configuredDatabase = options.database ?? (yield* Database.locationConfig);
const database = Database.layer(Database.resolveDatabaseLocation(configuredDatabase, paths.data));
const configured = yield* SandboxDriverLoader.loadAll(options.sandboxes ?? [], { hostCwd });
Expand All @@ -84,7 +95,7 @@ export const layer = (options: Options = {}) =>

return Control.layer.pipe(
Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(loop))),
Layer.provideMerge(State.layer({}, selection)),
Layer.provideMerge(State.layer({}, pool, references)),
Layer.provideMerge(Settings.layer(settingsOptions)),
Layer.provideMerge(SessionRuntime.layer),
Layer.provideMerge(sandboxes),
Expand Down
237 changes: 144 additions & 93 deletions packages/harness/src/plugin/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,6 @@ export interface Prepared {
readonly plugin: Plugin;
readonly options: PluginOptions;
}
export interface Catalog {
readonly add: (plugin: Plugin, source?: string, version?: string) => void;
readonly get: (id: string) => Plugin | undefined;
readonly has: (id: string) => boolean;
}

/** Catalogs belong to one harness configuration. Definitions never execute here. */
export const make = (): Catalog => {
const entries = new Map<string, { plugin: Plugin; source?: string; version?: string }>();
return {
add: (plugin, source, version) => {
entries.set(plugin.id, {
plugin,
...(source === undefined ? {} : { source }),
...(version === undefined ? {} : { version }),
});
},
get: (id) => entries.get(id)?.plugin,
has: (id) => entries.has(id),
};
};

export interface Options extends Loader.Options {
readonly builtins: ReadonlyArray<Plugin>;
}
Expand All @@ -64,45 +42,130 @@ const isPatch = (reference: PluginRef): reference is PluginPatch =>
("plugin" in reference || "package" in reference);

/**
* Resolve every reference to the ordered selection the harness runs.
* Everything that has been loaded, and every string that addresses it.
*
* This is what the **load pass** produces and the **config pass** consumes. Built-ins are in it
* from the start: they are objects the harness already holds, with no spec, digest or generation,
* and from here the config pass cannot tell them apart from a loaded package -- which is what lets
* `{ "plugin": "codework.tool.bash", "enabled": false }` go through the same code path as
* disabling anything else.
*/
export interface Pool {
/** By plugin ID. An ID is a key: the last module to claim it owns it. */
readonly plugins: ReadonlyMap<string, Plugin>;
/** Every string a loaded module answers to -- its reference, spec, path, package name -- to its ID. */
readonly aliases: ReadonlyMap<string, string>;
/** By ID, for diagnostics. Absent for a built-in and for a local source. */
readonly versions: ReadonlyMap<string, string>;
}

const emptyPool: Pool = { plugins: new Map(), aliases: new Map(), versions: new Map() };

/**
* The **load pass**: store lookup and ESM import, never the network.
*
* Runs at boot, at `reload`, and at any exchange that finds a reference it cannot satisfy. It
* deliberately knows nothing about `enabled`, options or order -- those are data over modules that
* are already loaded, they cost a walk of a short array, and they belong to every exchange.
*
* Loading is keyed by what a reference resolves to, so naming one module twice imports it once.
*/
export const load = Effect.fn("PluginCatalog.load")(function* (references: ReadonlyArray<PluginRef>, options: Options) {
const plugins = new Map<string, Plugin>();
const aliases = new Map<string, string>();
const versions = new Map<string, string>();
const seen = new Map<string, string>();

const remember = (plugin: Plugin, names: ReadonlyArray<string>, version?: string) => {
plugins.set(plugin.id, plugin);
aliases.set(plugin.id, plugin.id);
for (const name of names) aliases.set(name, plugin.id);
if (version !== undefined) versions.set(plugin.id, version);
};

/** Source metadata exists for diagnostics; a silent ID replacement is where it earns that. */
const note = (plugin: Plugin, source: string) =>
plugins.has(plugin.id)
? Effect.logDebug(`plugin ${plugin.id} redefined by ${source}; the earlier definition is discarded`)
: Effect.void;

for (const builtin of options.builtins) {
const plugin = yield* Loader.validate(builtin, { index: -1, reference: builtin.id }, true);
remember(plugin, []);
}

for (const [index, reference] of references.entries()) {
if (isPatch(reference)) continue; // configuration loads nothing.

// An entry reaching here is unvalidated and may be anything a JavaScript caller passed,
// including `null`: read an `id` off it only once it is known to have properties, or
// `PreparationError.reference` throws instead of reporting the bad entry.
const declared =
typeof reference === "string" ? reference : Predicate.hasProperty(reference, "id") ? reference.id : undefined;
const origin = { index, reference: typeof declared === "string" ? declared : `<plugin object #${index}>` };

if (typeof reference !== "string") {
// The very definition the pool already holds -- a built-in named by the default
// selection. Selecting one is not redefining it, so the reserved namespace stands.
if (typeof declared === "string" && plugins.get(declared) === reference) continue;
const plugin = yield* Loader.validate(reference, origin);
yield* note(plugin, "a supplied object");
remember(plugin, []);
continue;
}

const PluginSource.parse(reference, options.hostDir).pipe(
Effect.mapError((cause) => Loader.failure(origin, "source", cause)),
);
const key = target.kind === "local" ? target.path : target.spec;
const already = seen.get(key);
if (already !== undefined) {
aliases.set(reference, already);
continue;
}
const definition = yield* Loader.load(target, origin, options);
yield* note(definition.plugin, definition.version === undefined ? key : `${key}@${definition.version}`);
// `package` configuration matches the registered module string. Keep its canonical name
// and spec, or its local path, too, so a versioned entry can be configured without
// repeating the version and a relative path stays anchored consistently.
const names =
target.kind === "local"
? [target.path, ...(definition.name === undefined ? [] : [definition.name])]
: [PluginSource.canonical(target), target.spec];
remember(definition.plugin, [reference, ...names], definition.version);
seen.set(key, definition.plugin.id);
}

return { plugins, aliases, versions } satisfies Pool;
});

/** What the config pass could not satisfy, for §12.5's drift report. */
export interface Selected {
readonly selection: ReadonlyArray<Prepared>;
/** References naming a module the pool does not hold. Reported, never fetched. */
readonly missing: ReadonlyArray<string>;
}

/**
* The **config pass**: pure data over modules that are already loaded, run at every exchange.
*
* An array entry is a **module** — a definition object, a path, a `file:` URL, or a package
* spec. It is loaded if it is not already, and it takes the position it is written at, so
* naming a module again moves it.
* spec. It takes the position it is written at, so naming a module again moves it.
*
* An object entry is **configuration** for a plugin an earlier entry (or the built-in list)
* already selected, addressed by its ID (`plugin`) or the module string (`package`) that selected
* it. It never loads, installs or reorders anything, and a name matching nothing in the selection
* is ignored rather than failing the boot — a typo costs a debug line, never a fetch.
*
* Nothing here touches the disk, which is the whole point of the split: an `options` edit, an
* `enabled` flip or a reordering applies at the next exchange without importing anything.
*/
export const prepare = Effect.fn("PluginCatalog.prepare")(function* (
references: ReadonlyArray<PluginRef>,
options: Options,
) {
const catalog = make();
const hostDir = options.hostDir;
for (const builtin of options.builtins) {
catalog.add(yield* Loader.validate(builtin, { index: -1, reference: builtin.id }, true), "builtin");
}
/** Source metadata exists for diagnostics; a silent ID replacement is where it earns that. */
const note = (plugin: Plugin, source: string) =>
catalog.has(plugin.id)
? Effect.logDebug(`plugin ${plugin.id} redefined by ${source}; the earlier definition is discarded`)
: Effect.void;
/**
* Every string a loaded module was registered under, pointing at its ID. Built as the list is
* walked, so a `package` entry can only address a module an earlier entry loaded.
*/
const aliases = new Map<string, string>();
const operations = new Map<string, { enabled: boolean; origin: Loader.Origin; options: PluginOptions }>();
const loaded = new Map<string, Loader.Loaded>();
/**
* An ID is a key: the last module to claim it owns it, and the configuration written against
* it stays with the key rather than with whichever module is currently behind it. Two modules
* exporting one ID is the author's conflict to resolve; the replacement is logged.
*/
const select = (id: string, origin: Loader.Origin) =>
operations.set(id, { enabled: true, origin, options: operations.get(id)?.options ?? {} });
export const select = Effect.fn("PluginCatalog.select")(function* (references: ReadonlyArray<PluginRef>, pool: Pool) {
const operations = new Map<string, { enabled: boolean; index: number; options: PluginOptions }>();
const missing: string[] = [];
const choose = (id: string, index: number) =>
operations.set(id, { enabled: true, index, options: operations.get(id)?.options ?? {} });

for (const [index, reference] of references.entries()) {
if (isPatch(reference)) {
// A settings file is decoded before it reaches here; an embedder's array is not, so the
Expand All @@ -123,7 +186,7 @@ export const prepare = Effect.fn("PluginCatalog.prepare")(function* (
if (invalid !== undefined) {
return yield* Loader.failure(origin, "definition", new Error(`plugin entry ${invalid}`));
}
const id = reference.plugin === undefined ? aliases.get(reference.package) : reference.plugin;
const id = reference.plugin === undefined ? pool.aliases.get(reference.package) : reference.plugin;
const operation = id === undefined ? undefined : operations.get(id);
if (id === undefined || operation === undefined) {
// Misspelled, not installed, or not shipped by this build: there is nothing to
Expand All @@ -134,64 +197,52 @@ export const prepare = Effect.fn("PluginCatalog.prepare")(function* (
operations.set(id, {
enabled: reference.enabled ?? operation.enabled,
// Position stays with the entry that selected it: configuring never moves a plugin.
origin: operation.origin,
index: operation.index,
// The block is opaque, so the last entry owns it whole rather than merging into
// values the harness cannot interpret.
options: reference.options ?? operation.options,
});
continue;
}
// An entry reaching here is unvalidated and may be anything a JavaScript caller passed,
// including `null`: read an `id` off it only once it is known to have properties, or
// `PreparationError.reference` throws instead of reporting the bad entry.
const declared =
typeof reference === "string" ? reference : Predicate.hasProperty(reference, "id") ? reference.id : undefined;
const origin = { index, reference: typeof declared === "string" ? declared : `<plugin object #${index}>` };

if (typeof reference !== "string") {
// The very definition the catalog already holds — a built-in named by the default
// selection. Selecting one is not redefining it, so the reserved namespace stands.
if (typeof declared === "string" && catalog.get(declared) === reference) {
select(declared, origin);
continue;
}
const plugin = yield* Loader.validate(reference, origin);
yield* note(plugin, "a supplied object");
catalog.add(plugin, "object");
select(plugin.id, origin);
const declared = Predicate.hasProperty(reference, "id") ? reference.id : undefined;
if (typeof declared === "string" && pool.plugins.has(declared)) choose(declared, index);
continue;
}
const PluginSource.parse(reference, hostDir).pipe(
Effect.mapError((cause) => Loader.failure(origin, "source", cause)),
);
const key = target.kind === "local" ? target.path : target.spec;
const definition = loaded.get(key) ?? (yield* Loader.load(target, origin, options));
loaded.set(key, definition);
yield* note(definition.plugin, definition.version === undefined ? key : `${key}@${definition.version}`);
catalog.add(definition.plugin, definition.source, definition.version);
// `package` configuration matches the registered module string. Keep its canonical name
// and spec, or its local path, too, so a versioned entry can be configured without
// repeating the version and a relative path stays anchored consistently.
const names =
target.kind === "local"
? [target.path, ...(definition.name === undefined ? [] : [definition.name])]
: [PluginSource.canonical(target), target.spec];
for (const name of [reference, ...names]) aliases.set(name, definition.plugin.id);
select(definition.plugin.id, origin);
const id = pool.aliases.get(reference);
// The one case the pass cannot satisfy, which is what makes drift free to report: the
// entry is there, the module is not, and nothing here will fetch it.
if (id === undefined) missing.push(reference);
else choose(id, index);
}

const selection: Array<Prepared & { readonly index: number }> = [];
for (const [id, operation] of operations) {
// Disabled by a configuration entry. Every other operation was recorded beside the
// `catalog.add` that registered its definition, so the lookup below cannot miss.
if (!operation.enabled) continue;
const plugin = catalog.get(id);
const plugin = pool.plugins.get(id);
if (plugin !== undefined) {
selection.push({ plugin, options: Object.freeze(operation.options), index: operation.origin.index });
selection.push({ plugin, options: Object.freeze(operation.options), index: operation.index });
}
}
// Domain first, then where the entry was written. Comparing the index explicitly rather than
// leaning on a stable sort keeps the second half of the rule visible: composition inside one
// domain -- a plugin patching another's tool, or appending to the prompt it rendered -- still
// depends on the order those entries were written in.
selection.sort((a, b) => rank(a.plugin.kind) - rank(b.plugin.kind) || a.index - b.index);
return Object.freeze(selection.map(({ plugin, options }) => ({ plugin, options }) satisfies Prepared));
return {
selection: Object.freeze(selection.map(({ plugin, options }) => ({ plugin, options }) satisfies Prepared)),
missing,
} satisfies Selected;
});

/** Both passes, for a caller that wants the selection and has no reason to hold the pool. */
export const prepare = Effect.fn("PluginCatalog.prepare")(function* (
references: ReadonlyArray<PluginRef>,
options: Options,
) {
const pool = yield* load(references, options);
return (yield* select(references, pool)).selection;
});

export { emptyPool };
Loading