Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
refactor(harness): require an explicit host cwd, harden plugin setup
Two threads on the plugin branch.

Host cwd is now passed, never discovered. `process.cwd()` meant two
different things depending on who read it -- the OS process's directory, or
a session's sandbox mount -- and every consumer guessed. `Harness.layer`
now captures it once as `hostCwd` and threads it down; `Settings.Options.cwd`,
`PluginLoader.Options.hostCwd` and `SandboxDriverLoader.Options.hostCwd` are
required, so omitting one is a type error rather than a silent host read.

`Settings.layer` had never been passed a directory at all and resolved
against `process.cwd()` on its own. That worked only because the values
happened to agree; they stop agreeing the moment a layer is built from
elsewhere.

`sandbox/io.ts` and `sandbox/control.ts` keep theirs. `SandboxIO.host()`
means "the sandbox that is the host process", so the process directory is
its definition, not a fallback -- and the controller's local root must agree
with it. Parameterizing one half of that pair would let them diverge.

Plugin hardening: `setup` no longer double-wraps a typed SetupError;
`idPattern` requires exactly three segments; `validate` returns the author's
object rather than the Struct-decoded copy, which was stripping undeclared
properties and repointing `this` inside the `setup() {}` shorthand;
`guidelines.ts` folds into the default prompt plugin; `ToolRegistration`
moves to `tool/schema.ts` so the executor shares the contract without
importing plugin assembly; `errorMessage` is shared by the executor and the
loop's outer catch so one defect reads the same either way.
  • Loading branch information
sanchitrk committed Sep 11, 2026
commit ff099f17654461acfeb4e70fee17303a4c97619c
4 changes: 3 additions & 1 deletion packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ const runtime = Harness.layer({

Hooks belong to the tool registration. Sequential or parallel scheduling, selected with `Session.create({ tools: { execution: "parallel" } })`, covers the entire hook/handler pipeline. After runs for a started, interrupted tool if it has not already started, with a one-second cooperative cleanup grace period. The kernel owns result settlement.

`ctx.plugin.tools.update(name, patch)` rewrites a registration's model-facing prose without replacing the tool or its hooks. A read sees only earlier contributions, so a plugin patching `promptSnippet` or `promptGuidelines` must run _before_ the prompt plugin that indexes them — after it, the patch still reaches the wire description but no longer the system prompt.

Prompt plugins use `ctx.plugin.prompt.get()` and `set(string)`. Each `set` replaces the entire prompt, including with an empty string. Place a prompt plugin after the tools or prompt contributors it needs. Contributions close after setup; plugins receive event publication but no subscription or background lifecycle.

Omitting `plugins` selects Bash, the default prompt, and guidelines. An explicit array replaces that selection. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position.
Omitting `plugins` selects Bash then the default prompt. An explicit array replaces that selection. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position.

Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. Plugin discovery from settings and daemon lifecycles are not implemented.

Expand Down
23 changes: 14 additions & 9 deletions packages/harness/src/effect/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Control } from "../control.ts";
import { Database } from "../db/db.ts";
import { Event } from "../event/event.ts";
import { Global } from "../global.ts";
import { prepare, type PluginRef } from "../plugin/catalog.ts";
import { builtins, defaultRefs } from "../plugin/internal.ts";
import { RunnerExecute } from "../runner/execute.ts";
import { LLM } from "../runner/llm.ts";
import { Loop } from "../runner/loop.ts";
Expand All @@ -16,8 +18,6 @@ import { SandboxDriverRegistry } from "../sandbox/registry.ts";
import { SessionLive } from "../session/live.ts";
import { SessionRuntime } from "../session/runtime.ts";
import { Settings } from "../settings/settings.ts";
import { prepare, type PluginRef } from "../plugin/catalog.ts";
import { builtins, defaultRefs } from "../plugin/internal.ts";
import { State } from "../state/state.ts";

export interface Options {
Expand All @@ -30,16 +30,19 @@ export interface Options {
readonly llm?: LLM.Open;
}

export const layer = (options: Options = {}) => {
const base = process.cwd();
return Layer.unwrap(
export const layer = (options: Options = {}) =>
Layer.unwrap(
Effect.gen(function* () {
const paths = yield* Global.resolve(options.home === undefined ? {} : { home: options.home });
const plugins = yield* prepare(options.plugins ?? defaultRefs, { builtins, cache: paths.cache, base });
// The single sanctioned `process.cwd()` in the harness. Everything downstream
// takes the host directory as a required parameter, so no module can quietly
// fall back to the OS process's directory when it meant a session's mount.
const hostCwd = process.cwd();
const plugins = yield* prepare(options.plugins ?? defaultRefs, { builtins, cache: paths.cache, hostCwd });
const configuredDatabase = options.database ?? (yield* Database.locationConfig);
const global = Global.layerWith(paths);
const database = Database.layer(Database.resolveDatabaseLocation(configuredDatabase, paths.data));
const configured = yield* SandboxDriverLoader.loadAll(options.sandboxes ?? []);
const configured = yield* SandboxDriverLoader.loadAll(options.sandboxes ?? [], { hostCwd });
const drivers = SandboxDriverRegistry.layer(
SandboxDriver.withSource(MemorySandboxDriver.make().driver, "core"),
SandboxDriver.withSource(SqldbSandboxDriver.make().driver, "core"),
Expand All @@ -52,7 +55,10 @@ export const layer = (options: Options = {}) => {
Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(loop))),
Layer.provideMerge(State.layer({}, plugins)),
Layer.provideMerge(
Settings.layer(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }),
Settings.layer({
cwd: hostCwd,
...(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }),
}),
),
Layer.provideMerge(SessionRuntime.layer),
Layer.provideMerge(sandboxes),
Expand All @@ -64,6 +70,5 @@ export const layer = (options: Options = {}) => {
);
}),
);
};

export * as Harness from "./harness.ts";
16 changes: 13 additions & 3 deletions packages/harness/src/plugin/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,31 @@ export const prepare = Effect.fn("PluginCatalog.prepare")(function* (
options: Options,
) {
const catalog = make();
const base = options.base ?? process.cwd();
const hostCwd = options.hostCwd;
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;
const operations = new Map<string, { enabled: boolean; origin: Loader.Origin }>();
const loaded = new Map<string, Loader.Loaded>();
for (const [index, reference] of references.entries()) {
const origin = { index, reference: typeof reference === "string" ? reference : reference.id };
// `reference` must stay a string: an unvalidated object may carry no `id` at all, and
// `PreparationError.reference` would then throw instead of reporting the bad entry.
const declared = typeof reference === "string" ? reference : (reference as { id?: unknown }).id;
const origin = { index, reference: typeof declared === "string" ? declared : `<plugin object #${index}>` };
if (typeof reference !== "string") {
const plugin = yield* Loader.validate(reference, origin);
yield* note(plugin, "a supplied object");
catalog.add(plugin, "object");
operations.set(plugin.id, { enabled: true, origin });
continue;
}
const source = yield* Effect.try({
try: () => Loader.classify(reference, base),
try: () => Loader.classify(reference, hostCwd),
catch: (cause) => Loader.failure(origin, "source", cause),
});
if (source.kind === "id" || source.kind === "disable") {
Expand All @@ -59,6 +68,7 @@ export const prepare = Effect.fn("PluginCatalog.prepare")(function* (
const key = source.kind === "package" ? source.request.spec : source.path;
const definition = loaded.get(key) ?? (yield* Loader.load(source, 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);
operations.set(definition.plugin.id, { enabled: true, origin });
}
Expand Down
28 changes: 19 additions & 9 deletions packages/harness/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,32 @@ export const run = Effect.fn("PluginHost.run")(function* (
new SetupError({ pluginId: plugin.id, message: `Plugin setup failed: ${plugin.id}`, cause }),
});
}).pipe(
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(cause)
: Effect.fail(
new SetupError({
Effect.catchCause((cause) => {
if (Cause.hasInterrupts(cause)) return Effect.failCause(cause);
// Typed failures are already SetupErrors; wrap only defects (sync throws, dies)
// so the original plugin error is never nested twice. `Schema.is` on a tagged
// error class is an identity check, so a plugin throwing its own
// SetupError-shaped object still gets attributed to it.
const squashed = Cause.squash(cause);
return Effect.fail(
Schema.is(SetupError)(squashed)
? squashed
: new SetupError({
pluginId: plugin.id,
message: `Plugin setup failed: ${plugin.id}`,
cause: Cause.squash(cause),
cause: squashed,
}),
),
),
);
}),
);
}
return yield* Effect.try({
try: buckets.freeze,
catch: (cause) => new SetupError({ message: "Plugin snapshot freeze failed", cause }),
catch: (cause) =>
new SetupError({
message: `Plugin snapshot freeze failed: ${cause instanceof Error ? cause.message : String(cause)}`,
cause,
}),
});
});
return yield* setup.pipe(Effect.ensuring(Effect.sync(buckets.close)));
Expand Down
1 change: 1 addition & 0 deletions packages/harness/src/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { define, type Plugin, type Mount } from "./plugin.ts";
export type { SharedPluginContext, Config, Events, PromptResolver } from "./context.ts";
export type { PluginRegistry } from "./registry.ts";
export type { PluginRef } from "./catalog.ts";
export * as Tool from "./tool/schema.ts";
export * as Prompt from "./prompt/schema.ts";
12 changes: 8 additions & 4 deletions packages/harness/src/plugin/internal.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { bashPlugin } from "./internal/tool/bash.ts";
import { defaultPromptPlugin } from "./internal/prompt/default.ts";
import { guidelinesPromptPlugin } from "./internal/prompt/guidelines.ts";
import { bashPlugin } from "./internal/tool/bash.ts";

/** Every plugin the harness ships. Seeded into the catalog whether selected or not. */
export const builtins = Object.freeze([bashPlugin, defaultPromptPlugin]);

export const builtins = Object.freeze([bashPlugin, defaultPromptPlugin, guidelinesPromptPlugin]);
export const defaults = builtins;
/**
* The selection used when a caller passes no `plugins`. Tool contributors come
* first so the Prompt plugin that indexes them runs after they registered.
*/
export const defaultRefs = Object.freeze(builtins.map((plugin) => plugin.id));
110 changes: 101 additions & 9 deletions packages/harness/src/plugin/internal/prompt/default.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,109 @@
/*
* @file The built-in Prompt plugin: `codework.prompt.default`.
*
* Everything below `setup` is plugin-private. The Prompt domain stores one string
* and imposes no shape, so this file — not the kernel — owns the foundation, the
* tool index, the guideline dedupe and the working-directory line. A third party
* that wants a different prompt omits this plugin or `set`s over it.
*/

import { Effect } from "effect";
import type { AnyToolDef } from "../../../tools/tool.ts";
import type { PromptResolver, SharedPluginContext } from "../../context.ts";
import { define } from "../../plugin.ts";

/** The default coding-agent foundation, used unless `promptCustom` replaces it. */
const foundation = `You are an expert coding assistant operating inside codework, a coding agent harness.`;

/**
* Guidelines that hold regardless of which tools are registered.
*
* Kept short on purpose. Every line here is spent on every request, so a line
* earns its place only if a model measurably behaves worse without it.
*/
const standingGuidelines: ReadonlyArray<string> = [
"Be concise. Report what you did and what you found, not what you are about to do.",
"Quote exact paths and command output rather than paraphrasing them.",
"If a command fails, read the error before retrying.",
];

/** Collapse whitespace so two spellings of one guideline dedupe against each other. */
const normalize = (value: string): string => value.trim().replace(/\s+/g, " ");

/**
* Tool-contributed guidelines followed by the standing ones, normalized and
* deduplicated with first occurrence winning.
*
* Tool order is bucket order, which is the same order the index renders in and the
* same order the provider receives definitions in -- one ordering, not three.
*/
const guidelines = (tools: ReadonlyArray<AnyToolDef>): ReadonlyArray<string> => {
const seen = new Set<string>();
const collected: string[] = [];
for (const line of [...tools.flatMap((tool) => tool.promptGuidelines ?? []), ...standingGuidelines]) {
const normalized = normalize(line);
if (normalized.length === 0 || seen.has(normalized)) continue;
seen.add(normalized);
collected.push(normalized);
}
return collected;
};

/**
* The rendered tool index.
*
* `(none)` rather than an omitted section: a model told it has no tools behaves
* better than one left to infer it from silence.
*/
const toolIndex = (tools: ReadonlyArray<AnyToolDef>): string => {
const listed = tools.filter((tool) => tool.promptSnippet !== undefined && tool.promptSnippet.length > 0);
if (listed.length === 0) return "(none)";
return listed.map((tool) => `- ${tool.name}: ${tool.promptSnippet}`).join("\n");
};

interface Input {
readonly tools: ReadonlyArray<AnyToolDef>;
readonly directory: string;
readonly promptCustom?: string;
readonly promptSystemAppend?: string;
}

const assemble = (input: Input): string => {
const sections: string[] = [input.promptCustom ?? foundation];

sections.push(`Available tools:\n${toolIndex(input.tools)}`);

const lines = guidelines(input.tools);
if (lines.length > 0) sections.push(`Guidelines:\n${lines.map((line) => `- ${line}`).join("\n")}`);

const append = input.promptSystemAppend?.trim();
if (append !== undefined && append.length > 0) sections.push(append);

sections.push(`Current working directory: ${input.directory}`);

return sections.join("\n\n");
};

/**
* A caller slot, awaited lazily. A throw or rejection fails the snapshot, which the
* host attributes to this plugin's id.
*/
const slot = (ctx: SharedPluginContext, resolver: PromptResolver | undefined) =>
Effect.tryPromise(() => Promise.resolve(resolver?.(ctx)));

export const defaultPromptPlugin = define({
id: "codework.prompt.default",
setup: Effect.fn("DefaultPromptPlugin.setup")(function* (ctx) {
const custom = ctx.config.promptCustom;
const append = ctx.config.promptSystemAppend;
const foundation =
custom === undefined
? "You are a coding assistant."
: yield* Effect.tryPromise(() => Promise.resolve().then(() => custom(ctx)));
const extra =
append === undefined ? undefined : yield* Effect.tryPromise(() => Promise.resolve().then(() => append(ctx)));
ctx.plugin.prompt.set([foundation, extra].filter((part) => part !== undefined).join("\n\n"));
const custom = yield* slot(ctx, ctx.config.promptCustom);
const append = yield* slot(ctx, ctx.config.promptSystemAppend);
ctx.plugin.prompt.set(
assemble({
// Only tools registered by an earlier plugin are visible here.
tools: ctx.plugin.tools.list(),
directory: ctx.location.directory,
...(custom === undefined ? {} : { promptCustom: custom }),
...(append === undefined ? {} : { promptSystemAppend: append }),
}),
);
}),
});
10 changes: 0 additions & 10 deletions packages/harness/src/plugin/internal/prompt/guidelines.ts

This file was deleted.

37 changes: 29 additions & 8 deletions packages/harness/src/plugin/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { fileSystem as fs, hostPath as path } from "../host.ts";
import * as Package from "./package.ts";
import type { Plugin } from "./plugin.ts";

export const idPattern = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9.-]*$/;
/** `vendor.domain.context`, exactly three segments — a fourth would shadow a package name. */
export const idPattern = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*){2}$/;
const Id = Schema.String.check(Schema.isPattern(idPattern));
const Definition = Schema.Struct({
id: Id,
Expand All @@ -30,9 +31,13 @@ export const failure = (origin: Origin, phase: PreparationError["phase"], cause:
new PreparationError({ ...origin, phase, cause, ...(id === undefined ? {} : { id }) });

export const validate = Effect.fn("PluginLoader.validate")(function* (input: unknown, origin: Origin, builtin = false) {
const plugin = yield* Schema.decodeUnknownEffect(Definition)(input).pipe(
yield* Schema.decodeUnknownEffect(Definition)(input).pipe(
Effect.mapError((cause) => failure(origin, "definition", cause)),
);
// The author's own object, not the decoded copy: a `Struct` decode keeps only the
// declared keys, which would strip a plugin's other properties and leave `this`
// pointing at a clone inside the documented `setup() {}` shorthand.
const plugin = input as Plugin;
if (!builtin && plugin.id.startsWith("codework.")) {
return yield* failure(
origin,
Expand All @@ -50,13 +55,19 @@ export type Source =
| { readonly kind: "local"; readonly path: string }
| { readonly kind: "package"; readonly request: Package.Request };

export const classify = (source: string, base: string): Source => {
export const classify = (source: string, hostCwd: string): Source => {
if (source.startsWith("!")) {
const id = Schema.decodeSync(Id)(source.slice(1));
return { kind: "disable", id };
}
if (source.startsWith("file:") || source.startsWith("./") || source.startsWith("../") || path.isAbsolute(source)) {
return { kind: "local", path: source.startsWith("file:") ? fileURLToPath(source) : path.resolve(base, source) };
if (source.startsWith("file:")) {
// Both `new URL` and `fileURLToPath` silently read a relative `file:./x` as `/x`. A file
// URL names an absolute path or it is not one.
if (!source.startsWith("file:///")) throw new Error(`Not an absolute file URL: ${source}`);
return { kind: "local", path: fileURLToPath(source) };
}
if (source.startsWith("./") || source.startsWith("../") || path.isAbsolute(source)) {
return { kind: "local", path: path.resolve(hostCwd, source) };
}
if (Schema.is(Id)(source)) return { kind: "id", id: source };
return { kind: "package", request: Package.parse(source) };
Expand All @@ -71,7 +82,12 @@ const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location: string,
const stat = yield* fs.stat(location);
if (stat.type !== "Directory") return pathToFileURL(location).href;
const manifestPath = path.join(location, "package.json");
if (!(yield* fs.exists(manifestPath))) return pathToFileURL(path.join(location, "index.js")).href;
if (!(yield* fs.exists(manifestPath))) {
const fallback = path.join(location, "index.js");
if (!(yield* fs.exists(fallback)))
return yield* failure(origin, "source", new Error(`Directory has no package.json or index.js: ${location}`));
return pathToFileURL(fallback).href;
}
const manifest = yield* fs
.readFileString(manifestPath)
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))));
Expand All @@ -90,7 +106,8 @@ const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location: string,

export interface Options {
readonly cache: string;
readonly base?: string;
/** The OS process's directory, that constructor-relative references resolve against. Never read here. */
readonly hostCwd: string;
readonly import?: (url: string) => Promise<unknown>;
readonly install?: (
request: Package.Request,
Expand All @@ -116,7 +133,11 @@ export const load = Effect.fn("PluginLoader.load")(function* (
)
: {
url: yield* localUrl(source.path, origin).pipe(
Effect.mapError((cause) => failure(origin, "source", cause)),
// `localUrl` already reports its own source failures; only platform errors
// reaching here still need attribution.
Effect.mapError((cause) =>
Schema.is(PreparationError)(cause) ? cause : failure(origin, "source", cause),
),
),
};
const module = yield* Effect.tryPromise({
Expand Down
Loading