piebird-threads's mod: the threads rule and the project's open threads in the context

Plugins for coding agents: omp first, Claude Code and Codex where a plugin works there too, and the Tern terminal.
Each plugin's README shows it working in a short video.
| Plugin | What it gives you | Works in |
|---|---|---|
| piebird-kagi-search | Kagi web search, quick answers and Kagi News with your own Kagi account, from the plugin's own program; the kagi skill; a setting for how much the agent relies on Kagi over its own web search | omp, Claude Code, Codex, ChatGPT, Claude |
| piebird-agent-variants | The agent_variant_setup tool: temporary project-scoped variants of an agent, with explicit model routing | omp |
| piebird-sessions | The session_read tool: reads, pages and searches a session journal (omp's, Claude Code's or Codex's) by session id and line without opening it whole, with asks shown in full (each question, its options and what was picked or typed); and a line telling the agent its own session id | omp, Claude Code, Codex, ChatGPT, Claude |
| piebird-tern-bridge | The tern_lua tool, running Lua with Tern's window API in the Tern window holding omp's pane, and the tern skill | omp in Tern |
| piebird-threads | Your project's open threads in front of the agent: the files in the folder its settings name, in omp before a prompt whenever the list changes and shown in the chat, in Claude Code at the start of a conversation; a rule pointing to the threads skill, which gives the few parts of a thread file the list reads and leaves the rest to the agent | omp, Claude Code, Codex, ChatGPT |
| piebird-workflow-rules | Rules on how to work (proportion, thinking before building, fixing corrections as classes, project tooling, changelogs, asking for decisions) and the failure-classes skill | omp, Claude Code, Codex, ChatGPT |
| piebird-tern-ghostty-style | omp's chat in Tern drawn the way omp looks in Ghostty: one box per tool call, thoughts left open, bash as $ command, compact diffs and replies | omp in Tern |
On a Mac or a Windows PC with ChatGPT or Claude, download the installer from the releases page (piebird-<version>-macos-arm64.dmg for Apple silicon, -macos-x64.dmg for an Intel Mac, -windows-x64.exe for Windows) and open it. It lists the plugins and the apps on your computer they go to; pick the plugins and click Install. It finds omp, Claude Code and Codex too. docs/installer.md has what it writes where.
In a terminal, add this repository as a marketplace once, then install the plugins you want. piebird-kagi-search needs its vault, a program a marketplace can't carry, so it comes with the installer.
omp:
omp plugin marketplace add georgelubaretsi/piebird omp plugin install piebird-sessions@piebird
Claude Code:
claude plugin marketplace add georgelubaretsi/piebird claude plugin install piebird-threads@piebird
Codex:
codex plugin marketplace add georgelubaretsi/piebird codex plugin add piebird-threads@piebird
Codex runs a plugin's program (piebird-kagi-search's, piebird-sessions') only as the installer sets it up, so those two come to Codex through the installer.
A plugin that works in Tern carries a Tern plugin too (its so.stencil.tern/ folder), and omp brings it along: when omp starts in a Tern pane, it installs that Tern plugin if Tern lacks it, updates it after omp plugin upgrade, and says so in the chat. There is nothing to install in Tern yourself. A Tern plugin turned off in Tern's settings is left alone.
Issues and pull requests are welcome. This repository is exported from a private workspace, so a pull request is applied there by hand, with credit to its author, and arrives here as a new commit rather than as a merge.
Everything runs in the dev shell (Nix):
nix develop bun install bun run check # generated files, lint, format, typecheck, tests
AGENTS.md has the layout, commands and conventions. Tern's docs are not redistributed here; bun tools/sync-references.ts tern fetches them into reference/.
MIT. reference/agent-plugins/ is the Agent Plugins specification and schemas, redistributed under their own licenses (NOTICE).
hooks/register.ts 73 lines1// piebird-threads's Claude Code part, a mod: a block in the conversation's context holding the
2// plugin's rule (rules/piebird-threads.md, which points the agent to the threads skill) and the
3// project's open threads (core/update.ts). Claude Code computes it at the start of a conversation
4// and again after compaction or /clear.
5import type { Api, Register } from "claude-code";
6import type { Io, Place } from "../../kit/core/io";
7import { contextBlock } from "../../kit/claude/context";
8import { FS_READ_LIMIT, printerOf, stdoutPieces } from "../../kit/claude/pieces";
9import { joinText } from "../../kit/core/model";
10import { PLUGIN } from "../../core/folder";
11import { ruleText, threadsUpdate } from "../../core/update";
12
13/** Core's file reads, folder listings and program runs, made of this session's `$`. */
14function ioOf($: Api): Io {
15 return {
16 async readText(path) {
17 try {
18 return await $.fs.read(path);
19 } catch (error) {
20 // $.fs.read rejects a missing file as it does any other failure ("… failed: ENOENT").
21 if (!(await $.fs.exists(path))) return undefined;
22 throw error;
23 }
24 },
25 async readPieces(path) {
26 if (!(await $.fs.exists(path))) return undefined;
27 // Nothing is read or spawned until the first piece is asked for.
28 return (async function* () {
29 if ((await $.fs.stat(path)).size < FS_READ_LIMIT) {
30 yield await $.fs.read(path);
31 return;
32 }
33 const printer = printerOf(path, (await $.env.get("OS")) === "Windows_NT");
34 yield* stdoutPieces($.process.spawn(printer), printer);
35 })();
36 },
37 async list(path) {
38 try {
39 return (await $.fs.list(path)).map(entry => ({
40 name: entry.name,
41 directory: entry.kind === "dir",
42 modified: entry.kind === "file" ? entry.mtimeMs : 0,
43 }));
44 } catch (error) {
45 if (!(await $.fs.exists(path))) return undefined;
46 throw error;
47 }
48 },
49 // Rejects when the program can't start or outlives timeoutMs (default 30 s, at most 10 min).
50 run: (argv, options) => $.process.run(argv, { ...options }),
51 };
52}
53
54/** The person's home, the session's project root (it follows /cd), and the plugin's folder. */
55async function placeOf($: Api): Promise<Place> {
56 const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
57 if (home === undefined) throw new Error("neither HOME nor USERPROFILE is set");
58 return { home, project: await $.session.root(), plugin: $.plugin.root };
59}
60
61export const register: Register = (on, options) => {
62 on("prompt.context", async ($, e, next) => {
63 const r = await next(e);
64 const rule = ruleText(await $.fs.read(`${$.plugin.root}/rules/piebird-threads.md`));
65 // Claude Code reads plugin options from the person's settings only, never a project's.
66 const at = { choice: { person: options.folder }, last: undefined, now: Date.now() / 1000 };
67 // A list that can't be read leaves the rule alone in the block.
68 const list = await threadsUpdate(ioOf($), await placeOf($), at).catch(() => undefined);
69 const text = joinText(list === undefined ? [rule] : [rule, list], "paragraph");
70 return { ...r, blocks: [...r.blocks, contextBlock(PLUGIN, text)] };
71 });
72};
73../kit/core/io.ts 53 lines1// What a shell lends core code that needs the outside world: reading a file, whole or in pieces,
2// listing a folder and running a program. Each shell makes one from its own API (kit/bun: Bun; a
3// Claude Code mod: `$`, in its hooks module, where `$` calls must stay), so the code that uses it
4// stays pure.
5import type { CallSignal } from "./operations";
6
7export interface RunResult {
8 readonly exitCode: number;
9 readonly stdout: string;
10 readonly stderr: string;
11}
12
13export interface RunOptions {
14 /** Written to the program's stdin, then closed. */
15 readonly stdin?: string;
16 readonly timeoutMs?: number;
17 /** The program is killed when it aborts: the call that started it was cancelled. */
18 readonly signal?: CallSignal;
19}
20
21/** One entry of a folder. */
22export interface IoEntry {
23 readonly name: string;
24 readonly directory: boolean;
25 /** A file's last modification, milliseconds since the epoch; 0 for anything else. */
26 readonly modified: number;
27}
28
29export interface Io {
30 /** The file's text; undefined when there is no such file. Other failures throw. */
31 readText(path: string): Promise<string | undefined>;
32 /**
33 * The file's text in pieces as it is read, for a file too big to hold at once or read only in
34 * part: reading starts with the first piece asked for, and leaving the loop early stops it.
35 * Undefined when there is no such file; other failures throw while reading.
36 */
37 readPieces(path: string): Promise<AsyncIterable<string> | undefined>;
38 /** The folder's entries; undefined when there is no such folder. Other failures throw. */
39 list(path: string): Promise<readonly IoEntry[] | undefined>;
40 /** Runs argv[0] with the rest as arguments, without a shell. */
41 run(argv: readonly string[], options?: RunOptions): Promise<RunResult>;
42}
43
44/** Where the shell runs: the folders core code may need, as absolute paths. */
45export interface Place {
46 /** The person's home folder. */
47 readonly home: string;
48 /** The project the agent works in; settings files and paths in them are relative to it. */
49 readonly project: string;
50 /** The plugin's own folder, where it is installed. */
51 readonly plugin: string;
52}
53../kit/claude/context.ts 17 lines1// What a plugin tells the model at the start of a conversation, in a Claude Code mod: a block its
2// `prompt.context` hook adds to the context of the first user message, which Claude Code renders
3// under `# <name>` and computes again after compaction or /clear. The build copies kit/ into each
4// plugin that needs it (plugins/<name>/kit/); edit it in kit/.
5import type { ModelText } from "../core/model";
6
7/** One block of `prompt.context`; its name must be unique among the conversation's blocks. */
8export interface ContextBlock {
9 readonly name: string;
10 readonly text: string;
11}
12
13/** The block `name` with the model's text, for the hook to add to `(await next(e)).blocks`. */
14export function contextBlock(name: string, text: ModelText): ContextBlock {
15 return { name, text: text.text };
16}
17../kit/claude/pieces.ts 55 lines1// A file read in pieces in a Claude Code mod (`Io.readPieces`, kit/core/io.ts): `$.fs.read` holds
2// at most 4 MiB, so a bigger file is printed by a program the mod spawns and read as it comes. The
3// hooks module makes the `$` calls (kit/claude/io-of.md); this turns what they give into pieces.
4// The build copies kit/ into each plugin that needs it (plugins/<name>/kit/); edit it in kit/.
5
6/**
7 * Files below this size are read with `$.fs.read`. It refuses one over 4 MiB, and a file may grow
8 * between the size check and the read (an agent's own journal does), hence the margin.
9 */
10export const FS_READ_LIMIT = 3 * 1024 * 1024;
11
12/** What `$.process.spawn` gives: the program's output in pieces, then how it ended. */
13export interface Spawned extends AsyncIterable<{ readonly stream: "stdout" | "stderr"; readonly text: string }> {
14 readonly result: Promise<{ readonly code: number | null; readonly signal: string | null }>;
15}
16
17/** A program to spawn: its command line and the variables set for it. */
18export interface Printer {
19 readonly argv: readonly string[];
20 readonly env?: Readonly<Record<string, string>>;
21}
22
23/** Where the Windows printer finds its file: a variable, which no shell parses. */
24const FILE_VARIABLE = "PIEBIRD_PRINT_FILE";
25
26/** PowerShell copying the file's bytes to standard output as they are. */
27const COPY_TO_STDOUT = `$f=[IO.File]::OpenRead($env:${FILE_VARIABLE});$o=[Console]::OpenStandardOutput();$f.CopyTo($o);$o.Flush();$f.Close()`;
28
29/**
30 * The program that prints a file on this system: `cat`, or PowerShell on Windows. The path may
31 * come from the model, so on Windows it goes in a variable: on cmd's command line, a file named
32 * `x&calc&.jsonl` would run calc.
33 */
34export function printerOf(path: string, windows: boolean): Printer {
35 if (!windows) return { argv: ["cat", path] };
36 return {
37 argv: ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", COPY_TO_STDOUT],
38 env: { [FILE_VARIABLE]: path },
39 };
40}
41
42/**
43 * The program's standard output in pieces as it writes them. Leaving the loop early stops the
44 * program; one that fails throws with what it wrote to standard error.
45 */
46export async function* stdoutPieces(spawned: Spawned, printer: Printer): AsyncGenerator<string> {
47 let stderr = "";
48 for await (const piece of spawned) {
49 if (piece.stream === "stdout") yield piece.text;
50 else stderr += piece.text;
51 }
52 const { code, signal } = await spawned.result;
53 if (code !== 0) throw new Error(`${printer.argv[0]} failed (${signal ?? `exit ${code}`}): ${stderr.trim()}`);
54}
55../kit/core/model.ts 100 lines1// Everything a plugin sends a model comes from its Fluent catalog: only a Catalog can make
2// ModelText. Strict where Fluent would print a placeholder, and pure, so it runs in a Claude Code
3// mod too.
4import { FluentBundle, FluentNumber, FluentResource } from "./vendor/fluent/index.js";
5
6const MINT: unique symbol = Symbol("model text");
7
8/**
9 * Text for the model. Its brand keeps any other object with a `text` from passing as one, while
10 * every plugin's copy of the kit, and the tools that read them all, agree on the type; the build
11 * refuses the brand written anywhere but here (tools/build/purity.ts).
12 */
13export class ModelText {
14 readonly text: string;
15 readonly brand = "piebird-catalog";
16 constructor(mint: typeof MINT, text: string) {
17 if (mint !== MINT) throw new Error("ModelText comes from a Catalog");
18 this.text = text;
19 }
20 toString(): string {
21 return this.text;
22 }
23}
24
25/**
26 * Texts from the catalog one after another, each on its own line, or with an empty line between
27 * them as paragraphs. Line breaks are the one thing code adds: words come from the catalog.
28 */
29export function joinText(parts: readonly ModelText[], between: "line" | "paragraph" = "line"): ModelText {
30 return new ModelText(MINT, parts.map(p => p.text).join(between === "line" ? "\n" : "\n\n"));
31}
32
33/** The message ids formatted while a trace runs, for the "what the model sees" page. */
34let traced: string[] | undefined;
35
36/** Runs `run` and returns, with its value, the ids of the messages it formatted, in order, each once. */
37export async function traceMessages<T>(run: () => Promise<T>): Promise<{ value: T; ids: string[] }> {
38 const outer = traced;
39 traced = [];
40 try {
41 const value = await run();
42 return { value, ids: [...new Set(traced)] };
43 } finally {
44 traced = outer;
45 }
46}
47
48export type Variables = Readonly<Record<string, string | number>>;
49
50/** Numbers as written, not grouped the locale's way: a line number reads as a call takes it. */
51function plain(variables: Variables | undefined): Record<string, string | FluentNumber> | undefined {
52 if (variables === undefined) return undefined;
53 return Object.fromEntries(
54 Object.entries(variables).map(([k, v]) => [
55 k,
56 typeof v === "number" ? new FluentNumber(v, { useGrouping: false }) : v,
57 ]),
58 );
59}
60
61type Args<M, K extends keyof M> = M[K] extends undefined ? [] : [variables: M[K]];
62
63/** `M` maps each message id, and `id.attribute`, to its variables, or `undefined` when it takes none. */
64export class Catalog<M extends Record<string, Variables | undefined>> {
65 readonly #bundle: FluentBundle;
66
67 constructor(source: string) {
68 // Bidi isolation marks around placeables would reach the model as text.
69 this.#bundle = new FluentBundle("en-US", { useIsolating: false });
70 // The parser skips an entry it can't read; the build refuses those (tools/build/model.ts).
71 const errors = this.#bundle.addResource(new FluentResource(source));
72 if (errors.length > 0) throw new Error(`the catalog: ${errors.map(e => e.message).join("; ")}`);
73 }
74
75 text<K extends keyof M & string>(id: K, ...args: Args<M, K>): ModelText {
76 return this.#format(id, args[0]);
77 }
78
79 /**
80 * The message `id` chosen at run time, such as a vault's declared error. It throws, as `text`
81 * does, when the catalog lacks the message or `variables` lacks one it needs.
82 */
83 textById(id: string, variables?: Variables): ModelText {
84 return this.#format(id, variables);
85 }
86
87 #format(id: string, variables: Variables | undefined): ModelText {
88 const [name, attribute] = id.split(".", 2);
89 const message = this.#bundle.getMessage(name);
90 if (!message) throw new Error(`no message ${name} in the catalog`);
91 const pattern = attribute === undefined ? message.value : message.attributes[attribute];
92 if (!pattern) throw new Error(`message ${id} has no ${attribute === undefined ? "value" : "such attribute"}`);
93 const errors: Error[] = [];
94 const text = this.#bundle.formatPattern(pattern, plain(variables), errors);
95 if (errors.length > 0) throw new Error(`message ${id}: ${errors.map(e => e.message).join("; ")}`);
96 traced?.push(id);
97 return new ModelText(MINT, text);
98 }
99}
100../core/folder.ts 99 lines1// Where a project keeps its threads: the folder the plugin's "folder" setting names
2// (core/settings.json), else the one the nearest .omp/threads.json names, as
3// `{"folder": "<path>"}`, from the project's folder up. Nothing is guessed: without either, a
4// project has no threads list. A relative path is relative to where it was written: the person's
5// settings file to the home folder, the project's file and an agent's own screen to the project,
6// .omp/threads.json to the folder holding .omp/. The folder may lie outside the project (a sibling
7// notes repository, an absolute path): a project's settings are trusted as much as its code.
8import type { Io, Place } from "../kit/core/io";
9import type { ModelText } from "../kit/core/model";
10import type { AgentChoice } from "../kit/core/settings";
11import { agentChoices, readSettings } from "../kit/core/settings";
12import { model } from "./model.gen";
13import { declared } from "./settings.gen";
14
15export const PLUGIN = "piebird-threads";
16
17/** A project's threads folder: its absolute path, and its name as the project gives it. */
18export interface ThreadsFolder {
19 readonly path: string;
20 readonly name: string;
21}
22
23/** A failure the model is told about, in the catalog's words. */
24export class ThreadsError extends Error {
25 constructor(readonly said: ModelText) {
26 super(said.text);
27 }
28}
29
30function isAbsolute(path: string): boolean {
31 return path.startsWith("/") || path.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(path);
32}
33
34/** A Windows network share's root, `\\server\share` (either separator), written `//server/share`. */
35const SHARE = /^[\\/]{2}([^\\/]+)[\\/]+([^\\/]+)/;
36
37/**
38 * A path's root and the parts after it: the root is empty for `/`, a drive (`C:`), or a network
39 * share (`//server/share`), which `..` never leaves.
40 */
41function rooted(whole: string): { root: string; parts: string[] } {
42 const share = SHARE.exec(whole);
43 if (share) return { root: `//${share[1]}/${share[2]}`, parts: whole.slice(share[0].length).split(/[\\/]/) };
44 const [root = "", ...parts] = whole.split(/[\\/]/);
45 return { root, parts };
46}
47
48/** `path` taken from `base` when relative, its `.` and `..` resolved; separators become `/`. */
49export function resolvePath(base: string, path: string): string {
50 const { root, parts } = rooted(isAbsolute(path) ? path : `${base}/${path}`);
51 const kept: string[] = [];
52 for (const part of parts) {
53 if (part === "" || part === ".") continue;
54 if (part === "..") kept.pop();
55 else kept.push(part);
56 }
57 return `${root}/${kept.join("/")}`;
58}
59
60/** The folder holding `dir`, or `dir` itself at the root (`/`, a drive's or a network share's). */
61function parentOf(dir: string): string {
62 const share = SHARE.exec(dir);
63 if (share && /^[\\/]*$/.test(dir.slice(share[0].length))) return dir;
64 const at = Math.max(dir.lastIndexOf("/"), dir.lastIndexOf("\\"));
65 if (at < 0) return dir;
66 return at === 0 ? "/" : dir.slice(0, at);
67}
68
69/** A folder as the project wrote it, without trailing separators. */
70function trimmed(name: string): string {
71 return name.trim().replace(/[\\/]+$/, "");
72}
73
74/** The folder the nearest .omp/threads.json names from `dir` up; one that names none throws. */
75async function fromOmpFile(io: Io, dir: string): Promise<ThreadsFolder | undefined> {
76 for (let at = dir; ; at = parentOf(at)) {
77 const config = resolvePath(at, ".omp/threads.json");
78 const text = await io.readText(config);
79 if (text !== undefined) {
80 const parsed: unknown = JSON.parse(text);
81 const folder = typeof parsed === "object" && parsed !== null && "folder" in parsed ? parsed.folder : undefined;
82 if (typeof folder !== "string" || trimmed(folder) === "")
83 throw new ThreadsError(model.text("reason-no-folder", { config }));
84 return { path: resolvePath(at, trimmed(folder)), name: trimmed(folder) };
85 }
86 if (parentOf(at) === at) return undefined;
87 }
88}
89
90/** The project's threads folder at `place`, given the agent's own choices, or undefined when none is named. */
91export async function threadsFolder(io: Io, place: Place, choice?: AgentChoice): Promise<ThreadsFolder | undefined> {
92 const { values } = await readSettings(io, place, { plugin: PLUGIN, declared, ...agentChoices("folder", choice) });
93 const setting = values.folder;
94 if (setting === undefined || trimmed(setting.value) === "") return fromOmpFile(io, place.project);
95 const base = setting.from === "user" ? place.home : place.project;
96 const name = trimmed(setting.value);
97 return { path: resolvePath(base, name), name };
98}
99../core/update.ts 39 lines1// What the main agent is told about the project's threads, and when: the list when it differs from
2// the last one the agent got; once every thread is closed or dormant, a line saying there are none;
3// once nothing names a threads folder any more, a line saying earlier lists no longer apply. An
4// agent with no rules of its own gets the plugin's rule with the list.
5import type { Io, Place } from "../kit/core/io";
6import type { ModelText } from "../kit/core/model";
7import type { AgentChoice } from "../kit/core/settings";
8import { parseRule } from "../kit/core/rules";
9import { threadsFolder } from "./folder";
10import { threadsList } from "./list";
11import { model } from "./model.gen";
12
13/**
14 * What to tell the agent now, given the last text it got (undefined for none since the start or
15 * the last compaction), or undefined when that still holds. `now` is seconds since the epoch.
16 */
17export async function threadsUpdate(
18 io: Io,
19 place: Place,
20 at: { readonly choice?: AgentChoice; readonly last: unknown; readonly now: number },
21): Promise<ModelText | undefined> {
22 const folder = await threadsFolder(io, place, at.choice);
23 if (folder === undefined) {
24 // A list sent earlier must not stay the latest word once nothing names its folder.
25 const gone = model.text("no-folder");
26 return at.last === undefined || at.last === gone.text ? undefined : gone;
27 }
28 // A list sent earlier must not stay the latest word once every thread is closed or dormant.
29 const text =
30 (await threadsList(io, folder, at.now)) ??
31 (at.last === undefined ? undefined : model.text("none", { folder: folder.name }));
32 return text === undefined || text.text === at.last ? undefined : text;
33}
34
35/** A rule file's body, as omp gives an always-applied rule: front matter left out. */
36export function ruleText(markdown: string): ModelText {
37 return model.text("rule", { body: parseRule("threads", markdown).body });
38}
39../kit/core/operations.ts 173 lines1// A plugin's operations (core/operations.json) joined with their text from core/model.ftl, as its
2// generated core/operations.gen.ts carries them, and the contract between the shells and the code
3// that performs an operation. The build has checked the file and the text (tools/build/operations.ts);
4// this reads them into types and refuses what the code can't use.
5import type { ModelText } from "./model";
6
7export type JsonSchema = Readonly<Record<string, unknown>>;
8
9/** MCP's tool annotations, and whether the tool stays in front of tool search. */
10export interface ToolHints {
11 readonly readOnly?: boolean;
12 readonly destructive?: boolean;
13 readonly idempotent?: boolean;
14 readonly openWorld?: boolean;
15 readonly alwaysLoad?: boolean;
16 /** It runs code the model wrote. */
17 readonly runsCode?: boolean;
18}
19
20export interface Tool {
21 readonly name: string;
22 readonly description: ModelText;
23 /** The operation's input with each argument's description, from model.ftl, filled in. */
24 readonly inputSchema: JsonSchema;
25 readonly outputSchema: JsonSchema;
26 readonly hints: ToolHints;
27}
28
29export interface Operation {
30 readonly id: string;
31 readonly input: JsonSchema;
32 readonly output: JsonSchema;
33 readonly errors: readonly string[];
34 /** The plugin's vault performs it: it needs a secret. */
35 readonly vault: boolean;
36 readonly tool?: Tool;
37}
38
39/** What an operation ends with: its answer, as the model reads it and as data, or a declared error. */
40export type Outcome =
41 | { readonly ok: true; readonly text: ModelText; readonly data: unknown }
42 | { readonly ok: false; readonly error: string; readonly text: ModelText };
43
44/**
45 * A call's cancellation as core sees it: the platform's AbortSignal, which aborts when the caller
46 * cancels the call. Core names only what it reads, so it typechecks where there's no AbortSignal
47 * type (a Claude Code mod); a shell that hands it to its platform checks it is one.
48 */
49export interface CallSignal {
50 readonly aborted: boolean;
51}
52
53/**
54 * Performs one operation; the input is as the caller sent it, for the handler to check. `signal`
55 * aborts when the caller cancels the call: whatever the handler started (a program, a wait) stops.
56 */
57export type Handler = (input: unknown, signal?: CallSignal) => Promise<Outcome>;
58
59export type Handlers = Readonly<Record<string, Handler>>;
60
61/**
62 * What the model reads when a tool can't answer as its operation says, from the plugin's catalog:
63 * `crashed`, error-crashed, when its code fails instead (a bug, an unexpected error), with the
64 * failure's own message; `badArguments`, error-arguments, when the arguments don't fit the tool's
65 * input schema, with what doesn't. A plugin with tools has both (tools/build/operations.ts).
66 */
67export interface ToolWords {
68 readonly crashed: (cause: string) => ModelText;
69 readonly badArguments: (problem: string) => ModelText;
70}
71
72export interface Operations extends ToolWords {
73 readonly byId: ReadonlyMap<string, Operation>;
74 /** The operations the model gets as tools, in declaration order. */
75 readonly tools: readonly (Operation & { readonly tool: Tool })[];
76}
77
78/** The failure `error` as the model reads it: the catalog's error-crashed with its message. */
79export function crashText(operations: Operations, error: unknown): ModelText {
80 return operations.crashed(error instanceof Error ? error.message : String(error));
81}
82
83/** For a plugin without tools: it never needs these words. */
84const NO_TOOLS: ToolWords = {
85 crashed: () => {
86 throw new Error("this plugin has no tools");
87 },
88 badArguments: () => {
89 throw new Error("this plugin has no tools");
90 },
91};
92
93type Fields = Readonly<Record<string, unknown>>;
94
95function isFields(value: unknown): value is Fields {
96 return typeof value === "object" && value !== null && !Array.isArray(value);
97}
98
99function fields(value: unknown, at: string): Fields {
100 if (isFields(value)) return value;
101 throw new Error(`operations.json: ${at} isn't an object`);
102}
103
104function textFor(text: ReadonlyMap<string, ModelText>, id: string): ModelText {
105 const found = text.get(id);
106 if (found === undefined) throw new Error(`model.ftl: no ${id}`);
107 return found;
108}
109
110function hints(tool: Fields): ToolHints {
111 const flag = (k: string) => (typeof tool[k] === "boolean" ? { [k]: tool[k] } : {});
112 return {
113 ...flag("readOnly"),
114 ...flag("destructive"),
115 ...flag("idempotent"),
116 ...flag("openWorld"),
117 ...flag("alwaysLoad"),
118 ...flag("runsCode"),
119 };
120}
121
122function toolFor(id: string, op: Fields, text: ReadonlyMap<string, ModelText>): Tool | undefined {
123 if (op.tool === undefined) return undefined;
124 const tool = fields(op.tool, `/operations/${id}/tool`);
125 if (typeof tool.name !== "string") throw new Error(`operations.json: /operations/${id}/tool/name isn't text`);
126 const input = fields(op.input, `/operations/${id}/input`);
127 const props = fields(input.properties ?? {}, `/operations/${id}/input/properties`);
128 const described = Object.fromEntries(
129 Object.entries(props).map(([arg, schema]) => [
130 arg,
131 {
132 ...fields(schema, `/operations/${id}/input/properties/${arg}`),
133 description: textFor(text, `${id}.${arg}`).text,
134 },
135 ]),
136 );
137 return {
138 name: tool.name,
139 description: textFor(text, id),
140 inputSchema: { ...input, properties: described },
141 outputSchema: fields(op.output, `/operations/${id}/output`),
142 hints: hints(tool),
143 };
144}
145
146/**
147 * The operations in `source` (operations.json without `$schema`), each tool given its text, with
148 * `words` for a tool that can't answer; a plugin without tools needs none.
149 */
150export function parseOperations(
151 source: string,
152 text: ReadonlyMap<string, ModelText>,
153 words: ToolWords = NO_TOOLS,
154): Operations {
155 const parsed: unknown = JSON.parse(source);
156 const byId = new Map<string, Operation>();
157 for (const [id, value] of Object.entries(fields(fields(parsed, "").operations, "/operations"))) {
158 const op = fields(value, `/operations/${id}`);
159 const errors = Array.isArray(op.errors) ? op.errors.filter((e): e is string => typeof e === "string") : [];
160 const tool = toolFor(id, op, text);
161 const base = {
162 id,
163 input: fields(op.input, `/operations/${id}/input`),
164 output: fields(op.output, `/operations/${id}/output`),
165 errors,
166 vault: op.vault === true,
167 };
168 byId.set(id, tool === undefined ? base : { ...base, tool });
169 }
170 const tools = [...byId.values()].flatMap(op => (op.tool === undefined ? [] : [{ ...op, tool: op.tool }]));
171 return { byId, tools, ...words };
172}
173../kit/core/vendor/fluent/index.js 10 lines1/**
2 * A JavaScript implementation of Project Fluent, a localization
3 * framework designed to unleash the expressive power of the natural language.
4 *
5 * @module
6 */
7export { FluentBundle } from "./bundle.js";
8export { FluentResource } from "./resource.js";
9export { FluentType, FluentNone, FluentNumber, FluentDateTime, } from "./types.js";
10../kit/core/settings.ts 168 lines1// What applies for each of a plugin's settings, from four levels, lowest first: the plugin's
2// default, the project's file (<project>/.agents/piebird/settings.json), the person's file
3// (~/.agents/piebird/settings.json), and the person's choice in one agent's own settings screen.
4// A repo can't override the person. Each file holds a section per plugin, keyed by its name; a
5// value the declarations don't allow is reported and skipped, never applied. The shells read the
6// files and pass in what they hold; a path stays as written, for the shell to resolve against
7// the file it came from.
8import type { Declaration, Declared } from "./declared";
9import type { Io, Place } from "./io";
10
11export type Level = "default" | "project" | "user" | "agent";
12
13export interface Resolved {
14 readonly value: string;
15 readonly from: Level;
16}
17
18export type Issue = "not-json" | "not-an-object" | "unknown" | "secret" | "not-a-string" | "not-a-choice";
19
20/** A value a level holds that doesn't apply. `key` is empty when the plugin's whole section is wrong. */
21export interface Problem {
22 readonly level: Exclude<Level, "default">;
23 readonly key: string;
24 readonly issue: Issue;
25}
26
27/** An agent's own choice that follows the person's shared one. */
28export const SHARED = "shared";
29
30/** Choices by key, as an agent's own settings hold them. */
31export type Choices = Readonly<Record<string, string | undefined>>;
32
33export interface Sources {
34 /** The project's file, parsed; undefined when there is none. */
35 readonly project?: unknown;
36 /**
37 * The agent's own choices kept in the project (omp's .omp/plugin-overrides.json): a repo can
38 * hold them, so they rank with the project's file, over it, and below the person.
39 */
40 readonly agentProject?: Choices;
41 /** The person's file, parsed; undefined when there is none. */
42 readonly user?: unknown;
43 /** The person's choices in the agent's own screen, by key; SHARED, empty or undefined follows the files. */
44 readonly agent?: Choices;
45}
46
47/** `own`, an agent's choice for setting `d`, when it is one; else undefined, its problem in `problems`. */
48function agentValue(
49 d: Declaration,
50 own: string | undefined,
51 at: { key: string; level: Problem["level"]; problems: Problem[] },
52) {
53 if (own === undefined || own === SHARED || own === "") return undefined;
54 const issue = issueWith(d, own);
55 if (issue === undefined) return own;
56 at.problems.push({ level: at.level, key: at.key, issue });
57 return undefined;
58}
59
60function record(value: unknown): value is Readonly<Record<string, unknown>> {
61 return typeof value === "object" && value !== null && !Array.isArray(value);
62}
63
64function issueWith(d: Declaration, value: unknown): Issue | undefined {
65 if (typeof value !== "string") return "not-a-string";
66 if (d.type === "choice" && !d.choices.some(c => c.value === value)) return "not-a-choice";
67 return undefined;
68}
69
70interface Section {
71 readonly values: ReadonlyMap<string, string>;
72 readonly problems: readonly Problem[];
73}
74
75/** The plugin's section of one file: the values that apply, and what doesn't. */
76function section(plugin: string, declared: Declared, level: "project" | "user", file: unknown): Section {
77 const values = new Map<string, string>();
78 const problems: Problem[] = [];
79 const own = record(file) ? file[plugin] : file;
80 if (own === undefined) return { values, problems };
81 if (!record(own)) return { values, problems: [{ level, key: "", issue: "not-an-object" }] };
82 for (const [key, value] of Object.entries(own)) {
83 const d = declared.settings[key];
84 const issue = Object.hasOwn(declared.secrets, key) ? "secret" : d === undefined ? "unknown" : issueWith(d, value);
85 if (issue === undefined && typeof value === "string") values.set(key, value);
86 else problems.push({ level, key, issue: issue ?? "not-a-string" });
87 }
88 return { values, problems };
89}
90
91/** Each declared setting's value and the level it came from, with what was skipped. */
92export function resolveSettings(
93 plugin: string,
94 declared: Declared,
95 sources: Sources,
96): { values: Readonly<Record<string, Resolved>>; problems: readonly Problem[] } {
97 const project = section(plugin, declared, "project", sources.project);
98 const user = section(plugin, declared, "user", sources.user);
99 const problems: Problem[] = [...project.problems, ...user.problems];
100 const values: Record<string, Resolved> = {};
101 for (const [key, d] of Object.entries(declared.settings)) {
102 let resolved: Resolved = { value: d.default, from: "default" };
103 const fromProject = project.values.get(key);
104 if (fromProject !== undefined) resolved = { value: fromProject, from: "project" };
105 const fromAgentProject = agentValue(d, sources.agentProject?.[key], { key, level: "project", problems });
106 if (fromAgentProject !== undefined) resolved = { value: fromAgentProject, from: "project" };
107 const fromUser = user.values.get(key);
108 if (fromUser !== undefined) resolved = { value: fromUser, from: "user" };
109 const own = agentValue(d, sources.agent?.[key], { key, level: "agent", problems });
110 if (own !== undefined) resolved = { value: own, from: "agent" };
111 values[key] = resolved;
112 }
113 return { values, problems };
114}
115
116/** Where the settings files are, under the home folder and under the project. */
117export const SETTINGS_PATH = ".agents/piebird/settings.json";
118
119async function readFile(io: Io, path: string, level: "project" | "user", problems: Problem[]): Promise<unknown> {
120 const text = await io.readText(path);
121 if (text === undefined) return undefined;
122 try {
123 const parsed: unknown = JSON.parse(text);
124 return parsed;
125 } catch {
126 problems.push({ level, key: "", issue: "not-json" });
127 return undefined;
128 }
129}
130
131/** A plugin's declarations, and its agent's own choices when the agent has a screen for them. */
132export interface PluginSettings {
133 readonly plugin: string;
134 readonly declared: Declared;
135 readonly agent?: Choices;
136 readonly agentProject?: Choices;
137}
138
139/** One setting's choices in an agent's own settings: the person's, and one the agent keeps in the project. */
140export interface AgentChoice {
141 readonly person?: unknown;
142 readonly project?: unknown;
143}
144
145/** `choice` for the setting `key`, as readSettings takes an agent's choices. */
146export function agentChoices(key: string, choice: AgentChoice = {}): Pick<PluginSettings, "agent" | "agentProject"> {
147 const one = (value: unknown): Choices => (typeof value === "string" ? { [key]: value } : {});
148 return { agent: one(choice.person), agentProject: one(choice.project) };
149}
150
151/**
152 * The plugin's settings from the files at `place` and the agent's own screen. Working in the home
153 * folder, the two files are one: the person's.
154 */
155export async function readSettings(
156 io: Io,
157 place: Place,
158 { plugin, declared, agent, agentProject }: PluginSettings,
159): Promise<{ values: Readonly<Record<string, Resolved>>; problems: readonly Problem[] }> {
160 const problems: Problem[] = [];
161 const userPath = `${place.home}/${SETTINGS_PATH}`;
162 const projectPath = `${place.project}/${SETTINGS_PATH}`;
163 const user = await readFile(io, userPath, "user", problems);
164 const project = projectPath === userPath ? undefined : await readFile(io, projectPath, "project", problems);
165 const resolved = resolveSettings(plugin, declared, { project, agentProject, user, agent });
166 return { values: resolved.values, problems: [...problems, ...resolved.problems] };
167}
168../core/model.gen.ts 62 lines1// Generated from model.ftl by `bun run build`; edit model.ftl.
2import { Catalog } from "../kit/core/model";
3
4const SOURCE =
5 '## The open-threads list, put in front of the main agent.\n\nlist-header = Open threads in { $folder }/ ({ $count }{ $dormant ->\n [0] {""}\n *[other] ; { $dormant } dormant not shown\n }){ $listed ->\n [yes] :\n *[no] .\n }\nthread = - { $slug }\nwith-detail = { $line } \u00b7 { $detail }\nthread-waiting = waiting on you ({ $questions ->\n [one] { $questions } question\n *[other] { $questions } questions\n })\nthread-updated = updated { $updated }{ $uncommitted ->\n [yes] {" "}(uncommitted)\n *[no] {""}\n }\nthread-next = next: { $next }\nthread-area = { $area }\nthread-unreadable = - unreadable: { $source }: { $reason }\n\n## Why a thread file could not be listed, or the project\'s .omp/threads.json read.\n\nreason-no-front-matter = no front matter\nreason-no-field = no "{ $name }" in front matter\nreason-no-commit = no commit\nreason-git-exited = git exited { $status }\nreason-no-folder = { $config } names no "folder" (the threads folder, relative to the folder holding .omp/)\nreason-other = { $cause }\n\n## Once there is no list to show.\n\nnone = No open threads in { $folder }/.\nno-folder = No threads folder here: neither the plugin\'s folder setting nor a .omp/threads.json names one, so earlier thread lists no longer apply.\n\n## A rule the plugin ships, given to an agent that has no rules of its own: its body.\n\nrule = { $body }\n';
6
7export type Messages = {
8 "list-header": {
9 count: string | number;
10 dormant: string | number;
11 folder: string | number;
12 listed: string | number;
13 };
14 "no-folder": undefined;
15 none: {
16 folder: string | number;
17 };
18 "reason-git-exited": {
19 status: string | number;
20 };
21 "reason-no-commit": undefined;
22 "reason-no-field": {
23 name: string | number;
24 };
25 "reason-no-folder": {
26 config: string | number;
27 };
28 "reason-no-front-matter": undefined;
29 "reason-other": {
30 cause: string | number;
31 };
32 rule: {
33 body: string | number;
34 };
35 thread: {
36 slug: string | number;
37 };
38 "thread-area": {
39 area: string | number;
40 };
41 "thread-next": {
42 next: string | number;
43 };
44 "thread-unreadable": {
45 reason: string | number;
46 source: string | number;
47 };
48 "thread-updated": {
49 uncommitted: string | number;
50 updated: string | number;
51 };
52 "thread-waiting": {
53 questions: string | number;
54 };
55 "with-detail": {
56 detail: string | number;
57 line: string | number;
58 };
59};
60
61export const model = new Catalog<Messages>(SOURCE);
62../core/settings.gen.ts 8 lines1// Generated from settings.json by `bun run build`; edit settings.json.
2import { parseDeclared } from "../kit/core/declared";
3
4const SOURCE =
5 '{"settings":{"folder":{"type":"path","title":"Threads folder","about":"The folder of the project\'s open threads, one .md file each: relative to the project, or absolute. Empty: the folder the project\'s .omp/threads.json names, if any.","default":""}}}';
6
7export const declared = parseDeclared(SOURCE);
8