Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
35 changes: 35 additions & 0 deletions extras/codework-prompt-life/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# @acme/codework-prompt-life

An example third-party **prompt plugin**: it appends a short section on the meaning of life to
the system prompt.

The package uses the example `@acme` namespace to demonstrate how a third-party scoped plugin is
loaded and then configured by package name.

## Using it

```jsonc
{
"plugins": ["@acme/codework-prompt-life", { "package": "@acme/codework-prompt-life", "options": { "answer": 42 } }],
}
```

The string loads the module, the object configures it. Settings entries append after the
built-ins, so this lands after `codework.prompt.default` and composes on the prompt it rendered —
which is where a prompt plugin wants to be.

> **Not installable as written**, for the same reason as `@acme/codework-tool-proc`: it exports
> TypeScript, and Node will not strip types under `node_modules`. In this repository it is loaded
> by path as a dev-only example; a published plugin package ships built JavaScript.

| option | type | default | meaning |
| -------- | ------ | ------- | ---------------------------------- |
| `answer` | number | `42` | the number the short version gives |

## Worth noting

- **A prompt plugin replaces the whole prompt.** `ctx.plugin.prompt.set` takes the complete
string, so this plugin reads `get()` first and composes on it instead of discarding whatever
`codework.prompt.default` rendered.
- **It only sees earlier contributions.** Place it after the prompt plugins and tool plugins it
builds on; a plugin that runs before them sees nothing they registered.
25 changes: 25 additions & 0 deletions extras/codework-prompt-life/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@acme/codework-prompt-life",
"version": "0.0.0",
"private": true,
"description": "Example third-party codework plugin: a prompt section on the meaning of life.",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"check": "vp check",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^26.5.1",
"effect": "4.0.0-rc.115"
},
"peerDependencies": {
"@codeworksh/harness": "workspace:*",
"effect": "4.0.0-rc.115"
},
"engines": {
"node": ">=24.14.1"
}
}
41 changes: 41 additions & 0 deletions extras/codework-prompt-life/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* A third-party prompt plugin, vendored as a worked example. A prompt plugin composes on
* whatever an earlier one set, so it belongs *after* the tool plugins and prompt plugins it
* builds on — `ctx.plugin.prompt.get()` sees only contributions made before it ran:
*
* { "plugins": ["@acme/codework-prompt-life",
* { "package": "@acme/codework-prompt-life", "options": { "answer": 42 } }] }
*/
import { Plugin } from "@codeworksh/harness/effect";

const DEFAULT_ANSWER = 42;
const HEADING = "## On the meaning of life";

/** Unvalidated by the harness, so the plugin checks its own block and falls back in place. */
const readAnswer = (options: Plugin.PluginOptions): number => {
const answer = options["answer"];
return typeof answer === "number" && Number.isFinite(answer) ? answer : DEFAULT_ANSWER;
};

const section = (answer: number) =>
[
HEADING,
"",
"When the user asks what the meaning of life is, answer briefly and then get back to work:",
"",
`- The short version: ${answer}.`,
"- The engineering version: leave the codebase clearer than you found it.",
"- The honest version: nobody knows, and the tests still have to pass.",
].join("\n");

export default Plugin.define({
id: "acme.prompt.life",
setup(ctx, options) {
const existing = ctx.plugin.prompt.get();
// `set` replaces the whole prompt, so compose on what is already there rather than
// discarding another plugin's work.
ctx.plugin.prompt.set(
existing === undefined ? section(readAnswer(options)) : `${existing}\n\n${section(readAnswer(options))}`,
);
},
});
12 changes: 12 additions & 0 deletions extras/codework-prompt-life/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"paths": {
"@codeworksh/harness/effect": ["../../packages/harness/src/effect.ts"],
"@codeworksh/harness/sandbox": ["../../packages/harness/src/sandbox.ts"]
},
"tsBuildInfoFile": "../../.cache/typescript/codework-prompt-life.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
47 changes: 47 additions & 0 deletions extras/codework-tool-proc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# @acme/codework-tool-proc

An example third-party **tool plugin**: it registers `list_processes`, which runs `ps` in the
session's sandbox and returns the busiest processes.

It exists to be read. It uses only the public surfaces — `@codeworksh/harness/effect` for
`Plugin`/`Tool` and `@codeworksh/harness/sandbox` for the shell — and default-exports one plugin
object, which is what the loader imports.

## Using it

```jsonc
// codework.json, ~/.codework/settings.json, or a --user-config-dir
{
"plugins": ["@acme/codework-tool-proc@1.2.0", { "package": "@acme/codework-tool-proc", "options": { "limit": 40 } }],
}
```

Two entries, and they do different jobs. The **string** is the module: it installs the package
and puts the plugin in the selection. The **object** configures it — `package` names the module
it came from, and `{ "plugin": "acme.tool.proc" }` names the same plugin by its ID if you happen
to know it. An object never installs anything, so a name that matches nothing is ignored rather
than fetched.

> **This example is not installable as written.** It exports TypeScript from `src/`, and Node
> refuses to strip types under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). It
> lives in this repository as a dev-only workspace example, loaded by path:
> `"plugins": ["../extras/codework-tool-proc", { "package": "@acme/codework-tool-proc", … }]` —
> the `package` entry still works, because a local package is aliased by the name its manifest
> declares. A plugin package published for real ships built JavaScript and points `exports` at it.

| option | type | default | meaning |
| ------- | ---------------- | ------- | -------------------------------------- |
| `limit` | positive integer | `20` | how many rows to return, busiest first |

The block is opaque to the harness: it arrives as the second argument to `setup`, and this
plugin checks it and falls back rather than failing the boot on a bad value.

## Worth noting

- **It runs in the sandbox.** The tool executes through `SandboxIO.Shell`, not
`node:child_process`, so it lists processes wherever the session actually runs — this machine,
a container, or a remote sandbox.
- **Failures are typed.** A non-zero `ps` becomes a model-visible `ProcFailed` carrying stderr; a
shell that cannot run at all is a defect, because the model cannot act on it.
- **Order matters.** A tool plugin must run before the prompt plugin that indexes tools, or its
tool reaches the provider but is missing from the system prompt's list.
25 changes: 25 additions & 0 deletions extras/codework-tool-proc/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@acme/codework-tool-proc",
"version": "0.0.0",
"private": true,
"description": "Example third-party codework plugin: a tool that lists running processes.",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"check": "vp check",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^26.5.1",
"effect": "4.0.0-rc.115"
},
"peerDependencies": {
"@codeworksh/harness": "workspace:*",
"effect": "4.0.0-rc.115"
},
"engines": {
"node": ">=24.14.1"
}
}
130 changes: 130 additions & 0 deletions extras/codework-tool-proc/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* A third-party tool plugin, vendored as a worked example. It uses only the public plugin,
* tool and sandbox surfaces, and default-exports one plugin object — what the loader imports.
*
* Two entries: one names the module, the next configures it by the package name.
*
* { "plugins": ["@acme/codework-tool-proc@1.2.0",
* { "package": "@acme/codework-tool-proc", "options": { "limit": 40 } }] }
*
* In this repository the module entry is a path, because this example ships TypeScript and Node
* refuses to strip types under `node_modules`. A plugin package published for real ships built
* JavaScript; see the README.
*/
import { Plugin, Tool } from "@codeworksh/harness/effect";
import { SandboxIO } from "@codeworksh/harness/sandbox";
import { Effect, Schema } from "effect";

/** Rows to return when the entry carries no `limit`. */
const DEFAULT_LIMIT = 20;
/** `ps` fields, printed headerless (`=`) so there is nothing to skip while parsing. */
const COMMAND = "ps -eo pid=,pcpu=,pmem=,args=";
const ROW = /^\s*(\d+)\s+([\d.]+)\s+([\d.]+)\s+(.+)$/;

/**
* The plugin's own configuration block. The harness never inspects it, so every field is
* checked here and a malformed one falls back rather than failing the boot.
*/
const readLimit = (options: Plugin.PluginOptions): number => {
const limit = options["limit"];
return typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_LIMIT;
};

const ProcParams = Schema.Struct({
filter: Schema.optional(
Schema.String.annotate({ description: "Only list processes whose command line contains this text." }),
),
});

const ProcessRow = Schema.Struct({
pid: Schema.Finite,
cpu: Schema.Finite,
memory: Schema.Finite,
command: Schema.String,
});

const ProcSuccess = Schema.Struct({
processes: Schema.Array(ProcessRow),
/** How many matched in total, which is more than `processes.length` when `limit` cut the list. */
matched: Schema.Finite,
});

/** A non-zero `ps` is expected and model-visible: the model can retry with a different filter. */
class ProcFailed extends Schema.TaggedError<ProcFailed>()("ProcFailed", {
exitCode: Schema.Finite,
stderr: Schema.String,
}) {}

const parse = (stdout: string): ReadonlyArray<typeof ProcessRow.Type> => {
const rows: Array<typeof ProcessRow.Type> = [];
for (const line of stdout.split("\n")) {
const match = ROW.exec(line);
if (match === null) continue;
const [, pid, cpu, memory, command] = match;
rows.push({
pid: Number(pid),
cpu: Number(cpu),
memory: Number(memory),
command: (command ?? "").trim(),
});
}
return rows;
};

const table = (rows: ReadonlyArray<typeof ProcessRow.Type>): string =>
[
"PID CPU% MEM% COMMAND",
...rows.map(
(row) =>
`${String(row.pid).padEnd(8)} ${row.cpu.toFixed(1).padStart(5)} ${row.memory
.toFixed(1)
.padStart(5)} ${row.command}`,
),
].join("\n");

export default Plugin.define({
id: "acme.tool.proc",
setup: Effect.fn("ProcPlugin.setup")(function* (ctx, options) {
// The sandbox's shell, not `node:child_process`: the tool lists processes wherever the
// session actually runs — this machine, a container, or a remote sandbox.
const shell = yield* SandboxIO.Shell;
const limit = readLimit(options);
ctx.plugin.tools.add(
Tool.register(
Tool.make({
name: "list_processes",
label: "processes",
promptSnippet: "List the processes running in the session's environment.",
description:
"List running processes with their PID, CPU and memory share, and command line. " +
`Returns at most ${limit} rows, busiest first; pass \`filter\` to narrow by command line.`,
parameters: ProcParams,
success: ProcSuccess,
failure: ProcFailed,
encodeContent: (success) =>
success.processes.length === 0
? [{ type: "text", text: "No matching processes." }]
: [{ type: "text", text: table(success.processes) }],
encodeFailureContent: (failure) => [{ type: "text", text: failure.stderr }],
handler: (params) =>
Effect.gen(function* () {
const result = yield* shell
.exec(COMMAND)
// A shell that cannot run at all is infrastructure, not something the
// model can act on, so it becomes a defect rather than a tool failure.
.pipe(Effect.catchTag("ShellError", (cause) => Effect.die(cause)));
if (result.exitCode !== 0) {
return yield* new ProcFailed({ exitCode: result.exitCode, stderr: result.stderr });
}
const filter = params.filter?.toLowerCase();
const matched = parse(result.stdout).filter(
(row) => filter === undefined || row.command.toLowerCase().includes(filter),
);
const processes = [...matched].sort((a, b) => b.cpu - a.cpu).slice(0, limit);
return { processes, matched: matched.length } satisfies typeof ProcSuccess.Type;
}),
}),
),
);
}),
});
12 changes: 12 additions & 0 deletions extras/codework-tool-proc/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"paths": {
"@codeworksh/harness/effect": ["../../packages/harness/src/effect.ts"],
"@codeworksh/harness/sandbox": ["../../packages/harness/src/sandbox.ts"]
},
"tsBuildInfoFile": "../../.cache/typescript/codework-tool-proc.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
44 changes: 44 additions & 0 deletions extras/plugins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Single-file example plugins

Two plugins that are not packages: one file each, loaded by path. This is the shortest way to
write a project-local plugin — no build, no publish, no version.

| file | kind | contributes |
| ---------------- | ------ | -------------------------------------------------------------------------- |
| `local.ts` | tool | `project_fact`, which answers from conventions recorded in its own options |
| `house-style.ts` | prompt | a `## House style` section listing rules from its own options |

Both are inert without configuration: with no `options`, they register nothing rather than
adding an empty tool or an empty prompt section.

## Using them

```jsonc
// <project>/codework.json — `./` anchors to the directory of the file that declares it
{
"plugins": [
"./plugins/local.ts",
{ "package": "./plugins/local.ts", "options": { "facts": { "deploy": "vercel", "tests": "vp test" } } },
"./plugins/house-style.ts",
{ "package": "./plugins/house-style.ts", "options": { "rules": ["No `any` in TypeScript."] } },
],
}
```

Two entries per plugin: the module, then its configuration. A configuration entry names the
module by the same path (`package`) or the plugin by its ID (`plugin`, here `local.tool.facts`
and `local.prompt.house-style`); it never loads anything, so if the path is wrong the entry is
ignored rather than failing the boot.

A relative path resolves against the file that declared it, in both spellings, so the same entry
in `~/.codework/settings.json` names `~/.codework/plugins/local.ts`. Absolute paths, `file:` URLs
and `~/…` are taken as written.

## One thing settings cannot do

These land after the built-ins, and a prompt plugin only sees what registered before it. So
`project_fact` reaches the model with its own description but is missing from the system prompt's
tool list, and `house-style.ts` appends to the prompt `codework.prompt.default` already rendered
(which is what you want). A settings file names modules and configures plugins; it cannot
reorder the built-in selection. An embedder that needs the tool listed owns the whole order
through `Harness.layer({ plugins })`.
Loading