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(cli): link a session to the host directory its settings come from
- Add session link, so a session created without a host directory can be given one later instead of
  being destroyed and recreated
- Apply it at the next exchange, since settings are already re-read there: no reload and no restart,
  the project layer simply starts or stops being read
- Identify the session by id and default the path to the shell's directory, because a project cannot
  identify a session and the CLI already runs on the host
- Keep it apart from session relink, which moves where the work happens rather than where settings are
  discovered
- Treat a local path that exists as bytes already here, so linking a project whose plugins are local
  loads them; without this the boundary check only ever acted on store entries
- Leave a local source with no generation, so it can never be found superseded and reload stays the
  only way to pick up an edit to one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Loading branch information
sanchitrk and claude committed Sep 19, 2026
commit 75c798590748fc5f7d313ee19d32708cb50fe93c
33 changes: 33 additions & 0 deletions packages/codework/src/cli/cmd/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,39 @@ export const Cmd = Spec.make("codework", {
{ command: "codework serve --port 0", description: "Choose an available local port" },
],
}),
Spec.make("session", {
description: "Manage sessions",
commands: [
Spec.make("link", {
description: "Point a session at the host directory its settings come from",
params: {
session: Argument.String("session-id").pipe(Argument.withDescription("The session to link")),
path: Argument.String("path").pipe(
Argument.withDescription("Project directory; defaults to the shell's"),
Argument.optional,
),
unlink: Flag.Boolean("unlink").pipe(
Flag.withDescription("Clear it, returning the session to the user layer alone"),
Flag.withDefault(false),
),
server: Flag.String("server").pipe(
Flag.withDescription("Base URL of the running codework server"),
Flag.withDefault("http://127.0.0.1:7433"),
),
},
examples: [
{
command: "codework session link ses_01h9",
description: "Link it to the directory you are standing in",
},
{
command: "codework session link ses_01h9 --unlink",
description: "Return it to the user layer alone",
},
],
}),
],
}),
Spec.make("plugin", {
description: "Manage plugins",
commands: [
Expand Down
47 changes: 47 additions & 0 deletions packages/codework/src/cli/cmd/handlers/session/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Session } from "@codeworksh/harness/effect";
import { Effect, Option, Path } from "effect";
import { Runtime } from "../../../../framework/runtime.ts";
import { reportFailure } from "../../../error.ts";
import { writeOut } from "../../../output.ts";
import { Client } from "../../../../server/client.ts";
import { Cmd } from "../../cmd.ts";

/**
* Sets `hostDir` on an existing session.
*
* **The session ID identifies it; the path is the target.** A project cannot identify a session --
* it may have many or none, and it is the session that carries the field.
*
* The path defaults to the shell's directory, which is both the common case (you are standing in
* the project you want to link) and safe to default: the CLI runs on the host, so the trust
* question about naming host paths is about remote clients, not about a local shell's own cwd.
*
* Not `session relink`, which moves a session to another checkout of the same project. Different
* axes -- `relink` changes where the *work* happens, `link` changes where *settings* are
* discovered -- and conflating them would recreate the confusion the two names exist to avoid.
*/
export default Runtime.handler(
Cmd.commands.session.commands.link,
Effect.fn("CLI.session.link")(function* ({ session, path, unlink, server }) {
const program = Effect.gen(function* () {
const nodePath = yield* Path.Path;
const rpc = yield* Client.make;
const sessionId = Session.SessionSchema.ID.make(session);
const hostDir = unlink ? undefined : nodePath.resolve(Option.getOrElse(path, () => "."));

const info = yield* rpc["session.link"]({
sessionId,
...(hostDir === undefined ? {} : { hostDir }),
});
// Settings are re-read every exchange, so this applies at the next one: no reload, no
// restart, the project layer simply starts or stops being read.
yield* writeOut(
info.hostDir === undefined
? `Unlinked ${info.id}; it now reads the user settings alone.\n`
: `Linked ${info.id} to ${info.hostDir}\n`,
);
});

return yield* program.pipe(Effect.provide(Client.layer(server)), Effect.scoped, Effect.catch(reportFailure));
}),
);
3 changes: 3 additions & 0 deletions packages/codework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { Runtime } from "./framework/runtime.ts";
const Handlers = Runtime.handlers(Cmd, {
run: () => import("./cli/cmd/handlers/run.ts"),
serve: () => import("./cli/cmd/handlers/serve.ts"),
session: {
link: () => import("./cli/cmd/handlers/session/link.ts"),
},
plugin: {
add: () => import("./cli/cmd/handlers/plugin/add.ts"),
check: () => import("./cli/cmd/handlers/plugin/check.ts"),
Expand Down
19 changes: 19 additions & 0 deletions packages/codework/src/server/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@ export const Api = RpcGroup.make(
success: SessionInfo,
error: SessionStore.SessionNotFoundError,
}),
/**
* Point an existing session at a host directory, or clear it with `null`.
*
* Not `session.relink`: that moves where the *work* happens, this changes where *settings* are
* discovered. Conflating them would recreate exactly the `cwd`/`hostDir` confusion the split
* exists to remove, which is why they sound alike and stay apart.
*
* The project layer starts being read at the session's next exchange -- settings are re-read
* every exchange, so nothing has to be reloaded or restarted.
*/
Rpc.make("session.link", {
payload: {
sessionId: Session.SessionSchema.ID,
/** Absent unlinks, returning the session to the user layer alone. */
hostDir: optional(Schema.String),
},
success: SessionInfo,
error: SessionStore.SessionNotFoundError,
}),
Rpc.make("session.relink", {
payload: {
sessionId: Session.SessionSchema.ID,
Expand Down
4 changes: 4 additions & 0 deletions packages/codework/src/server/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export const layer = Contract.Api.toLayer(
"session.info": Effect.fnUntraced(function* ({ sessionId }) {
return yield* sessionInfo(sessionId);
}),
"session.link": Effect.fnUntraced(function* ({ sessionId, hostDir }) {
const handle = yield* Session.link({ sessionId, hostDir: hostDir ?? null });
return toSessionInfo(yield* handle.info);
}),
"session.relink": Effect.fnUntraced(function* ({ sessionId, sandbox, directory }) {
const selection = sandbox === undefined ? undefined : yield* Sandbox.resolve(sandbox);
const selected = selection?.info;
Expand Down
22 changes: 21 additions & 1 deletion packages/codework/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DateTime, Deferred, Effect, Fiber, Layer, Option, Queue, Schema, Stream
import { HttpServer } from "effect/unstable/http";
import { RpcTest } from "effect/unstable/rpc";
import { execFile } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -83,6 +83,26 @@ describe("server", () => {
}).pipe(Effect.scoped, Effect.provide(layer()), Effect.runPromise);
});

it("session.link sets and clears a session's host directory", () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "codework-server-link-")));
homes.push(root);

return Effect.gen(function* () {
const rpc = yield* RpcTest.makeClient(Contract.Api);
const created = yield* rpc["session.create"]({});
expect(created.hostDir).toBeUndefined();

const linked = yield* rpc["session.link"]({ sessionId: created.id, hostDir: root });
expect(linked.hostDir).toBe(root);
// Stored, not held in the call: a later reader sees it too.
expect((yield* rpc["session.info"]({ sessionId: created.id })).hostDir).toBe(root);

// Omitting the field unlinks, returning the session to the user layer alone.
const cleared = yield* rpc["session.link"]({ sessionId: created.id });
expect(cleared.hostDir).toBeUndefined();
}).pipe(Effect.scoped, Effect.provide(layer()), Effect.runPromise);
});

it("plugin.reload reports the loaded set", () =>
Effect.gen(function* () {
const rpc = yield* RpcTest.makeClient(Contract.Api);
Expand Down
19 changes: 14 additions & 5 deletions packages/harness/src/effect/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Control } from "../control.ts";
import { Database } from "../db/db.ts";
import { Event } from "../event/event.ts";
import { Global } from "../global.ts";
import { fileSystem } from "../host.ts";
import { EventRegistry } from "../event/registry.ts";
import { follow, load, type PluginRef, type Pool } from "../plugin/catalog.ts";
import { PluginSource } from "../plugin/source.ts";
Expand Down Expand Up @@ -98,14 +99,22 @@ export const layer = (options: Options = {}) =>
* verb that puts bytes on disk. A reference that does not parse, or names a local
* path, is simply not filed.
*/
const filed = (reference: string): Effect.Effect<Option.Option<PluginStore.Entry>> =>
type Here = Option.Option<{ readonly generation?: number }>;
const filed = (reference: string): Effect.Effect<Here> =>
PluginSource.parse(reference, hostCwd).pipe(
Effect.flatMap((target) =>
Effect.flatMap((target): Effect.Effect<Here, unknown> =>
target.kind === "local"
? Effect.succeedNone
: PluginStore.resolve(target, paths.cache).pipe(Effect.map(Option.fromUndefinedOr)),
? // On disk by definition, so it can be loaded now -- but never filed, so it
// has no generation and can never be found superseded. That is what
// leaves `reload` as the only way to pick up an edit to one.
fileSystem
.exists(target.path)
.pipe(Effect.map((there): Here => (there ? Option.some({}) : Option.none())))
: PluginStore.resolve(target, paths.cache).pipe(
Effect.map((entry): Here => Option.fromUndefinedOr(entry)),
),
),
Effect.orElseSucceed(() => Option.none<PluginStore.Entry>()),
Effect.orElseSucceed((): Here => Option.none()),
);

/*
Expand Down
20 changes: 16 additions & 4 deletions packages/harness/src/plugin/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,15 +303,23 @@ export const follow = Effect.fn("PluginCatalog.follow")(function* (
* someone has to remember: the default installer fetches, and there is no way to reach it here.
*/
options: Options & { readonly install: NonNullable<Options["install"]> },
/** What the store already holds for a reference. Never a fetch. */
filed: (reference: string) => Effect.Effect<Option.Option<{ readonly generation: number }>>,
/**
* Whether a reference's bytes are already on this machine, and at which generation.
*
* A local path answers yes with no generation: it is on disk by definition, and it is never
* filed, so nothing can supersede it. That is the difference between "can be loaded now" and
* "has moved" -- a local plugin is the first and never the second, which is why `reload`
* exists and covers only it.
*/
filed: (reference: string) => Effect.Effect<Option.Option<{ readonly generation?: number }>>,
) {
let moved = false;

for (const reference of references) {
if (typeof reference !== "string" || pool.aliases.has(reference)) continue;
// Configured but not loaded. Reported by `select` either way; acted on only when the
// bytes are already here.
// bytes are already here -- which covers a package the store holds and a local path that
// exists, and excludes anything that would have to be fetched.
if (Option.isSome(yield* filed(reference))) {
moved = true;
break;
Expand All @@ -322,7 +330,11 @@ export const follow = Effect.fn("PluginCatalog.follow")(function* (
for (const origin of pool.origins.values()) {
if (origin.generation === undefined) continue;
const current = yield* filed(origin.reference);
if (Option.isSome(current) && current.value.generation > origin.generation) {
if (
Option.isSome(current) &&
current.value.generation !== undefined &&
current.value.generation > origin.generation
) {
moved = true;
break;
}
Expand Down
119 changes: 119 additions & 0 deletions packages/harness/test/plugin.passes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,122 @@ describe("reload", () => {
expect(prompts.at(-1)?.endsWith("before")).toBe(true);
}));
});

describe("linking a session to a host directory", () => {
it("starts reading the project's plugins at the next exchange", () =>
withProject(async ({ root, project }) => {
// A plugin the project declares, which nothing has any reason to load yet.
const module = join(project, "linked.mjs");
await writeFile(
module,
[
"export default {",
" id: 'acme.prompt.linked',",
" kind: 'prompt',",
" setup: (ctx) => ctx.plugin.prompt.set(`${ctx.plugin.prompt.get() ?? ''}linked`),",
"};",
].join("\n"),
);
await writeFile(join(project, ".codework", "settings.jsonc"), JSON.stringify({ plugins: [module] }));

// The process boots somewhere else entirely, so the project is not its own.
const elsewhere = join(root, "elsewhere");
await mkdir(elsewhere, { recursive: true });

const prompts: string[] = [];
const open = immediateOpen();
await Effect.runPromise(
Effect.gen(function* () {
// No host directory: this session has no project layer, which is a normal
// state rather than a missing value.
const created = yield* Session.create({ directory: project });
expect((yield* created.info).hostDir).toBeUndefined();

const run = () =>
created
.prompt({ text: "go", delivery: "followUp" })
.pipe(Effect.andThen(created.resume()), Effect.andThen(created.wait()));
yield* run();

yield* Session.link({ sessionId: created.id, hostDir: project });
expect((yield* created.info).hostDir).toBe(project);

// Settings are re-read every exchange, so the project layer simply starts
// being read -- and its plugin is local, so its bytes were always here.
yield* run();
}).pipe(
Effect.provide(
Harness.layer({
home: join(root, "home"),
hostCwd: elsewhere,
database: ":memory:",
llm: (request, signal) => {
prompts.push(request.context.systemPrompt ?? "");
return open(request, signal);
},
}),
),
Effect.scoped,
Effect.timeout("20 seconds"),
Effect.orDie,
),
);

expect(prompts).toHaveLength(2);
expect(prompts[0]).not.toContain("linked");
expect(prompts[1]?.endsWith("linked")).toBe(true);
}));

it("returns a session to the user layer when unlinked", () =>
withProject(async ({ root, project }) => {
const module = join(project, "linked.mjs");
await writeFile(
module,
[
"export default {",
" id: 'acme.prompt.linked',",
" kind: 'prompt',",
" setup: (ctx) => ctx.plugin.prompt.set(`${ctx.plugin.prompt.get() ?? ''}linked`),",
"};",
].join("\n"),
);
await writeFile(join(project, ".codework", "settings.jsonc"), JSON.stringify({ plugins: [module] }));

const prompts: string[] = [];
const open = immediateOpen();
await Effect.runPromise(
Effect.gen(function* () {
const created = yield* Session.create({ directory: project, hostDir: project });
const run = () =>
created
.prompt({ text: "go", delivery: "followUp" })
.pipe(Effect.andThen(created.resume()), Effect.andThen(created.wait()));
yield* run();

yield* Session.link({ sessionId: created.id, hostDir: null });
expect((yield* created.info).hostDir).toBeUndefined();
// The module stays loaded in the pool; it is simply no longer selected, which
// is the config pass doing its job rather than anything being unloaded.
yield* run();
}).pipe(
Effect.provide(
Harness.layer({
home: join(root, "home"),
hostCwd: project,
database: ":memory:",
llm: (request, signal) => {
prompts.push(request.context.systemPrompt ?? "");
return open(request, signal);
},
}),
),
Effect.scoped,
Effect.timeout("20 seconds"),
Effect.orDie,
),
);

expect(prompts[0]?.endsWith("linked")).toBe(true);
expect(prompts[1]).not.toContain("linked");
}));
});