Skip to content

Commit 34398dd

Browse files
authored
Merge pull request #33 from codeworksh/fix-plugin-loader
fix!: rework plugin configuration block
2 parents fe21399 + 0ee433a commit 34398dd

57 files changed

Lines changed: 1536 additions & 232 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# @acme/codework-prompt-life
2+
3+
An example third-party **prompt plugin**: it appends a short section on the meaning of life to
4+
the system prompt.
5+
6+
The package uses the example `@acme` namespace to demonstrate how a third-party scoped plugin is
7+
loaded and then configured by package name.
8+
9+
## Using it
10+
11+
```jsonc
12+
{
13+
"plugins": ["@acme/codework-prompt-life", { "package": "@acme/codework-prompt-life", "options": { "answer": 42 } }],
14+
}
15+
```
16+
17+
The string loads the module, the object configures it. Settings entries append after the
18+
built-ins, so this lands after `codework.prompt.default` and composes on the prompt it rendered —
19+
which is where a prompt plugin wants to be.
20+
21+
> **Not installable as written**, for the same reason as `@acme/codework-tool-proc`: it exports
22+
> TypeScript, and Node will not strip types under `node_modules`. In this repository it is loaded
23+
> by path as a dev-only example; a published plugin package ships built JavaScript.
24+
25+
| option | type | default | meaning |
26+
| -------- | ------ | ------- | ---------------------------------- |
27+
| `answer` | number | `42` | the number the short version gives |
28+
29+
## Worth noting
30+
31+
- **A prompt plugin replaces the whole prompt.** `ctx.plugin.prompt.set` takes the complete
32+
string, so this plugin reads `get()` first and composes on it instead of discarding whatever
33+
`codework.prompt.default` rendered.
34+
- **It only sees earlier contributions.** Place it after the prompt plugins and tool plugins it
35+
builds on; a plugin that runs before them sees nothing they registered.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@acme/codework-prompt-life",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Example third-party codework plugin: a prompt section on the meaning of life.",
6+
"type": "module",
7+
"exports": {
8+
".": "./src/index.ts"
9+
},
10+
"scripts": {
11+
"check": "vp check",
12+
"typecheck": "tsc --noEmit"
13+
},
14+
"devDependencies": {
15+
"@types/node": "^26.5.1",
16+
"effect": "4.0.0-rc.115"
17+
},
18+
"peerDependencies": {
19+
"@codeworksh/harness": "workspace:*",
20+
"effect": "4.0.0-rc.115"
21+
},
22+
"engines": {
23+
"node": ">=24.14.1"
24+
}
25+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* A third-party prompt plugin, vendored as a worked example. A prompt plugin composes on
3+
* whatever an earlier one set, so it belongs *after* the tool plugins and prompt plugins it
4+
* builds on — `ctx.plugin.prompt.get()` sees only contributions made before it ran:
5+
*
6+
* { "plugins": ["@acme/codework-prompt-life",
7+
* { "package": "@acme/codework-prompt-life", "options": { "answer": 42 } }] }
8+
*/
9+
import { Plugin } from "@codeworksh/harness/effect";
10+
11+
const DEFAULT_ANSWER = 42;
12+
const HEADING = "## On the meaning of life";
13+
14+
/** Unvalidated by the harness, so the plugin checks its own block and falls back in place. */
15+
const readAnswer = (options: Plugin.PluginOptions): number => {
16+
const answer = options["answer"];
17+
return typeof answer === "number" && Number.isFinite(answer) ? answer : DEFAULT_ANSWER;
18+
};
19+
20+
const section = (answer: number) =>
21+
[
22+
HEADING,
23+
"",
24+
"When the user asks what the meaning of life is, answer briefly and then get back to work:",
25+
"",
26+
`- The short version: ${answer}.`,
27+
"- The engineering version: leave the codebase clearer than you found it.",
28+
"- The honest version: nobody knows, and the tests still have to pass.",
29+
].join("\n");
30+
31+
export default Plugin.define({
32+
id: "acme.prompt.life",
33+
setup(ctx, options) {
34+
const existing = ctx.plugin.prompt.get();
35+
// `set` replaces the whole prompt, so compose on what is already there rather than
36+
// discarding another plugin's work.
37+
ctx.plugin.prompt.set(
38+
existing === undefined ? section(readAnswer(options)) : `${existing}\n\n${section(readAnswer(options))}`,
39+
);
40+
},
41+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"allowImportingTsExtensions": true,
5+
"paths": {
6+
"@codeworksh/harness/effect": ["../../packages/harness/src/effect.ts"],
7+
"@codeworksh/harness/sandbox": ["../../packages/harness/src/sandbox.ts"]
8+
},
9+
"tsBuildInfoFile": "../../.cache/typescript/codework-prompt-life.tsbuildinfo"
10+
},
11+
"include": ["src/**/*.ts"]
12+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# @acme/codework-tool-proc
2+
3+
An example third-party **tool plugin**: it registers `list_processes`, which runs `ps` in the
4+
session's sandbox and returns the busiest processes.
5+
6+
It exists to be read. It uses only the public surfaces — `@codeworksh/harness/effect` for
7+
`Plugin`/`Tool` and `@codeworksh/harness/sandbox` for the shell — and default-exports one plugin
8+
object, which is what the loader imports.
9+
10+
## Using it
11+
12+
```jsonc
13+
// codework.json, ~/.codework/settings.json, or a --user-config-dir
14+
{
15+
"plugins": ["@acme/codework-tool-proc@1.2.0", { "package": "@acme/codework-tool-proc", "options": { "limit": 40 } }],
16+
}
17+
```
18+
19+
Two entries, and they do different jobs. The **string** is the module: it installs the package
20+
and puts the plugin in the selection. The **object** configures it — `package` names the module
21+
it came from, and `{ "plugin": "acme.tool.proc" }` names the same plugin by its ID if you happen
22+
to know it. An object never installs anything, so a name that matches nothing is ignored rather
23+
than fetched.
24+
25+
> **This example is not installable as written.** It exports TypeScript from `src/`, and Node
26+
> refuses to strip types under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). It
27+
> lives in this repository as a dev-only workspace example, loaded by path:
28+
> `"plugins": ["../extras/codework-tool-proc", { "package": "@acme/codework-tool-proc", … }]` —
29+
> the `package` entry still works, because a local package is aliased by the name its manifest
30+
> declares. A plugin package published for real ships built JavaScript and points `exports` at it.
31+
32+
| option | type | default | meaning |
33+
| ------- | ---------------- | ------- | -------------------------------------- |
34+
| `limit` | positive integer | `20` | how many rows to return, busiest first |
35+
36+
The block is opaque to the harness: it arrives as the second argument to `setup`, and this
37+
plugin checks it and falls back rather than failing the boot on a bad value.
38+
39+
## Worth noting
40+
41+
- **It runs in the sandbox.** The tool executes through `SandboxIO.Shell`, not
42+
`node:child_process`, so it lists processes wherever the session actually runs — this machine,
43+
a container, or a remote sandbox.
44+
- **Failures are typed.** A non-zero `ps` becomes a model-visible `ProcFailed` carrying stderr; a
45+
shell that cannot run at all is a defect, because the model cannot act on it.
46+
- **Order matters.** A tool plugin must run before the prompt plugin that indexes tools, or its
47+
tool reaches the provider but is missing from the system prompt's list.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@acme/codework-tool-proc",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Example third-party codework plugin: a tool that lists running processes.",
6+
"type": "module",
7+
"exports": {
8+
".": "./src/index.ts"
9+
},
10+
"scripts": {
11+
"check": "vp check",
12+
"typecheck": "tsc --noEmit"
13+
},
14+
"devDependencies": {
15+
"@types/node": "^26.5.1",
16+
"effect": "4.0.0-rc.115"
17+
},
18+
"peerDependencies": {
19+
"@codeworksh/harness": "workspace:*",
20+
"effect": "4.0.0-rc.115"
21+
},
22+
"engines": {
23+
"node": ">=24.14.1"
24+
}
25+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* A third-party tool plugin, vendored as a worked example. It uses only the public plugin,
3+
* tool and sandbox surfaces, and default-exports one plugin object — what the loader imports.
4+
*
5+
* Two entries: one names the module, the next configures it by the package name.
6+
*
7+
* { "plugins": ["@acme/codework-tool-proc@1.2.0",
8+
* { "package": "@acme/codework-tool-proc", "options": { "limit": 40 } }] }
9+
*
10+
* In this repository the module entry is a path, because this example ships TypeScript and Node
11+
* refuses to strip types under `node_modules`. A plugin package published for real ships built
12+
* JavaScript; see the README.
13+
*/
14+
import { Plugin, Tool } from "@codeworksh/harness/effect";
15+
import { SandboxIO } from "@codeworksh/harness/sandbox";
16+
import { Effect, Schema } from "effect";
17+
18+
/** Rows to return when the entry carries no `limit`. */
19+
const DEFAULT_LIMIT = 20;
20+
/** `ps` fields, printed headerless (`=`) so there is nothing to skip while parsing. */
21+
const COMMAND = "ps -eo pid=,pcpu=,pmem=,args=";
22+
const ROW = /^\s*(\d+)\s+([\d.]+)\s+([\d.]+)\s+(.+)$/;
23+
24+
/**
25+
* The plugin's own configuration block. The harness never inspects it, so every field is
26+
* checked here and a malformed one falls back rather than failing the boot.
27+
*/
28+
const readLimit = (options: Plugin.PluginOptions): number => {
29+
const limit = options["limit"];
30+
return typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_LIMIT;
31+
};
32+
33+
const ProcParams = Schema.Struct({
34+
filter: Schema.optional(
35+
Schema.String.annotate({ description: "Only list processes whose command line contains this text." }),
36+
),
37+
});
38+
39+
const ProcessRow = Schema.Struct({
40+
pid: Schema.Finite,
41+
cpu: Schema.Finite,
42+
memory: Schema.Finite,
43+
command: Schema.String,
44+
});
45+
46+
const ProcSuccess = Schema.Struct({
47+
processes: Schema.Array(ProcessRow),
48+
/** How many matched in total, which is more than `processes.length` when `limit` cut the list. */
49+
matched: Schema.Finite,
50+
});
51+
52+
/** A non-zero `ps` is expected and model-visible: the model can retry with a different filter. */
53+
class ProcFailed extends Schema.TaggedError<ProcFailed>()("ProcFailed", {
54+
exitCode: Schema.Finite,
55+
stderr: Schema.String,
56+
}) {}
57+
58+
const parse = (stdout: string): ReadonlyArray<typeof ProcessRow.Type> => {
59+
const rows: Array<typeof ProcessRow.Type> = [];
60+
for (const line of stdout.split("\n")) {
61+
const match = ROW.exec(line);
62+
if (match === null) continue;
63+
const [, pid, cpu, memory, command] = match;
64+
rows.push({
65+
pid: Number(pid),
66+
cpu: Number(cpu),
67+
memory: Number(memory),
68+
command: (command ?? "").trim(),
69+
});
70+
}
71+
return rows;
72+
};
73+
74+
const table = (rows: ReadonlyArray<typeof ProcessRow.Type>): string =>
75+
[
76+
"PID CPU% MEM% COMMAND",
77+
...rows.map(
78+
(row) =>
79+
`${String(row.pid).padEnd(8)} ${row.cpu.toFixed(1).padStart(5)} ${row.memory
80+
.toFixed(1)
81+
.padStart(5)} ${row.command}`,
82+
),
83+
].join("\n");
84+
85+
export default Plugin.define({
86+
id: "acme.tool.proc",
87+
setup: Effect.fn("ProcPlugin.setup")(function* (ctx, options) {
88+
// The sandbox's shell, not `node:child_process`: the tool lists processes wherever the
89+
// session actually runs — this machine, a container, or a remote sandbox.
90+
const shell = yield* SandboxIO.Shell;
91+
const limit = readLimit(options);
92+
ctx.plugin.tools.add(
93+
Tool.register(
94+
Tool.make({
95+
name: "list_processes",
96+
label: "processes",
97+
promptSnippet: "List the processes running in the session's environment.",
98+
description:
99+
"List running processes with their PID, CPU and memory share, and command line. " +
100+
`Returns at most ${limit} rows, busiest first; pass \`filter\` to narrow by command line.`,
101+
parameters: ProcParams,
102+
success: ProcSuccess,
103+
failure: ProcFailed,
104+
encodeContent: (success) =>
105+
success.processes.length === 0
106+
? [{ type: "text", text: "No matching processes." }]
107+
: [{ type: "text", text: table(success.processes) }],
108+
encodeFailureContent: (failure) => [{ type: "text", text: failure.stderr }],
109+
handler: (params) =>
110+
Effect.gen(function* () {
111+
const result = yield* shell
112+
.exec(COMMAND)
113+
// A shell that cannot run at all is infrastructure, not something the
114+
// model can act on, so it becomes a defect rather than a tool failure.
115+
.pipe(Effect.catchTag("ShellError", (cause) => Effect.die(cause)));
116+
if (result.exitCode !== 0) {
117+
return yield* new ProcFailed({ exitCode: result.exitCode, stderr: result.stderr });
118+
}
119+
const filter = params.filter?.toLowerCase();
120+
const matched = parse(result.stdout).filter(
121+
(row) => filter === undefined || row.command.toLowerCase().includes(filter),
122+
);
123+
const processes = [...matched].sort((a, b) => b.cpu - a.cpu).slice(0, limit);
124+
return { processes, matched: matched.length } satisfies typeof ProcSuccess.Type;
125+
}),
126+
}),
127+
),
128+
);
129+
}),
130+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"allowImportingTsExtensions": true,
5+
"paths": {
6+
"@codeworksh/harness/effect": ["../../packages/harness/src/effect.ts"],
7+
"@codeworksh/harness/sandbox": ["../../packages/harness/src/sandbox.ts"]
8+
},
9+
"tsBuildInfoFile": "../../.cache/typescript/codework-tool-proc.tsbuildinfo"
10+
},
11+
"include": ["src/**/*.ts"]
12+
}

‎extras/plugins/README.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Single-file example plugins
2+
3+
Two plugins that are not packages: one file each, loaded by path. This is the shortest way to
4+
write a project-local plugin — no build, no publish, no version.
5+
6+
| file | kind | contributes |
7+
| ---------------- | ------ | -------------------------------------------------------------------------- |
8+
| `local.ts` | tool | `project_fact`, which answers from conventions recorded in its own options |
9+
| `house-style.ts` | prompt | a `## House style` section listing rules from its own options |
10+
11+
Both are inert without configuration: with no `options`, they register nothing rather than
12+
adding an empty tool or an empty prompt section.
13+
14+
## Using them
15+
16+
```jsonc
17+
// <project>/codework.json — `./` anchors to the directory of the file that declares it
18+
{
19+
"plugins": [
20+
"./plugins/local.ts",
21+
{ "package": "./plugins/local.ts", "options": { "facts": { "deploy": "vercel", "tests": "vp test" } } },
22+
"./plugins/house-style.ts",
23+
{ "package": "./plugins/house-style.ts", "options": { "rules": ["No `any` in TypeScript."] } },
24+
],
25+
}
26+
```
27+
28+
Two entries per plugin: the module, then its configuration. A configuration entry names the
29+
module by the same path (`package`) or the plugin by its ID (`plugin`, here `local.tool.facts`
30+
and `local.prompt.house-style`); it never loads anything, so if the path is wrong the entry is
31+
ignored rather than failing the boot.
32+
33+
A relative path resolves against the file that declared it, in both spellings, so the same entry
34+
in `~/.codework/settings.json` names `~/.codework/plugins/local.ts`. Absolute paths, `file:` URLs
35+
and `~/…` are taken as written.
36+
37+
## One thing settings cannot do
38+
39+
These land after the built-ins, and a prompt plugin only sees what registered before it. So
40+
`project_fact` reaches the model with its own description but is missing from the system prompt's
41+
tool list, and `house-style.ts` appends to the prompt `codework.prompt.default` already rendered
42+
(which is what you want). A settings file names modules and configures plugins; it cannot
43+
reorder the built-in selection. An embedder that needs the tool listed owns the whole order
44+
through `Harness.layer({ plugins })`.

0 commit comments

Comments
 (0)