Skip to content

Latest commit

 

History

History
282 lines (215 loc) · 19.3 KB

File metadata and controls

282 lines (215 loc) · 19.3 KB

Development Rules

Default Context

This repo contains multiple packages, but packages/coding-agent/ is the primary focus. Unless otherwise specified, assume work refers to this package.

Terminology: When the user says "agent" or asks "why is agent doing X", they mean the coding-agent package implementation, not you (the assistant). The coding-agent is a CLI tool — questions about its behavior refer to code in packages/coding-agent/, not your current session.

Package Structure

Package Description
packages/ai Multi-provider LLM client with streaming support
packages/catalog Model catalog: bundled models.json, provider descriptors, model identity/classification
packages/agent Agent runtime with tool calling and state management
packages/coding-agent Main CLI application (primary focus)
packages/tui Terminal UI library with differential rendering
packages/natives Bindings for native text/image/grep operations
packages/stats Local observability dashboard (reactor stats)
packages/utils Shared utilities (logger, streams, temp files)
crates/reactor-natives Rust crate for performance-critical text/grep ops

Catalog import convention: code in this repo imports catalog values (bundled models, model-thinking helpers, identity, descriptors, model manager/cache) from @reactor/catalog/<module> — never via @reactor/ai. The ai barrel re-exports only the model/effort types its own signatures use (Model, Api, ThinkingConfig, Effort, …); type-only imports of those from @reactor/ai are fine.

GitHub

Unless user tells you exactly what to write:

  • Never comment on GitHub (issues, PRs, discussions).
  • Never create issues on GitHub.

ReActor fork policy

This checkout is the public KNN-07/ReActor fork. The product surface is reactor, @reactor/*, .reactor, and REACTOR_*; do not add an legacy command alias, legacy dot-directory fallback, or legacy-data importer. Upstream remains available as the upstream remote and is pinned in .upstream/oh-my-pi.json.

To synchronize a reviewed upstream range, keep the worktree clean and run bun run sync:upstream --to <sha>. It creates a sync/oh-my-pi-<short-sha> branch, applies .upstream/rename-map.json, reports skipped protected paths, and leaves changes uncommitted. Resolve and review the patch, then run bun run sync:upstream:finalize; that command runs the full check, test, smoke, Python, install-method, and release-binary gates before making the attributed sync commit. Never merge a floating upstream branch or overwrite packages/autonomy while syncing.

Reliable autonomy is explicitly opt-in through reactor run or /react start. Its default limits are eight continuations, 60 active minutes, two equivalent failures, and required verification evidence. Approval requests, user input, interrupts, exhausted limits, repeated equivalent failures, and indeterminate tool calls pause the goal. Restarted goals load paused; external side effects cannot be rolled back, so the lifecycle guarantee is never blindly replaying a started tool call.

Code Quality

  • No any unless absolutely necessary.
  • NEVER use ReturnType<> — use the actual type name.
  • NEVER use inline imports — no await import(), no import("pkg").Type in type positions, no dynamic type imports. Always top-level.
  • Check node_modules for external API types instead of guessing.
  • Barrel exports: prefer export * from "./module" over named re-exports, including export type { ... } from. In pure index.ts barrels, use star re-exports even for single-specifier cases. If stars create ambiguity, remove the redundant export path; do not keep duplicates.
  • Class privacy: use ES #private fields; leave externally accessible members bare. No private/protected/public keyword on fields or methods, except on constructor parameter properties where TypeScript requires it (e.g. constructor(private readonly session: ToolSession)).
  • Promises: use Promise.withResolvers() instead of new Promise((resolve, reject) => ...).
  • Prompts: never build prompts in code (no inline strings, template literals, or concatenation). Prompts live in static .md files; use Handlebars for dynamic content. Import them via import content from "./prompt.md" with { type: "text" } — not readFile.
  • Worker scripts: workers re-enter the CLI entrypoint; never spawn separate worker entry modules. cli.ts declares itself as the worker host at startup (declareWorkerHostEntry() from @reactor/utils/env) and dispatches hidden argv selectors (__reactor_worker_stats_sync, __reactor_worker_tab, __reactor_worker_js_eval, __reactor_worker_tiny_inference) before loading the command registry. Spawn sites use:
    import { workerHostEntry } from "@reactor/utils";
    const hostEntry = workerHostEntry();
    const worker = hostEntry
    ? new Worker(hostEntry, { type: "module", argv: ["__reactor_worker_<name>"] })
    : new Worker(new URL("./<worker>.ts", import.meta.url).href, { type: "module" });
    When the process was started from the reactor CLI — source cli.ts, npm-bundle dist/cli.js, or compiled binary — workerHostEntry() is Bun.main and the worker re-enters the single entry module, so no per-worker --compile entrypoints or bundle entries exist. Outside a CLI host (bun test, SDK embedding, standalone reactor-stats) it returns null and the direct-module fallback loads the worker source. New worker kinds MUST add their selector to the dispatch table in cli.ts and keep the fallback branch. History: with { type: "file" } only copied the entry as a raw asset (workers crashed silently in compiled binaries — issues #1011, #1027), and the later literal-path + extra-entrypoint pattern required keeping spawn literals and two build scripts in sync (issue #1150). The smoke probe below is the live validation of this contract. Validate any new worker with the dedicated smoke probe: reactor --smoke-test spawns the stats sync worker and the tiny-model subprocess, pings them, and exits — it's wired into ci:test:smoke and scripts/install-tests/run-ci.sh so binary, source-link, and tarball installs all exercise it. Add a sibling smoke if the new worker is on a different module graph.

Bun Over Node

Use Bun APIs where they provide a cleaner alternative; fall back to node:* only for what Bun doesn't cover. Never spawn shell commands for operations with proper APIs (e.g., don't Bun.spawnSync(["mkdir", "-p", dir]) — use mkdirSync).

Quick reference

Operation Use Not
File read/write Bun.file(), Bun.write() readFileSync, writeFileSync
Spawn process $`cmd`, Bun.spawn() child_process
Sleep Bun.sleep(ms) setTimeout promise
Binary lookup $which("git") from @reactor/utils spawnSync(["which", "git"])
HTTP server Bun.serve() http.createServer()
SQLite bun:sqlite better-sqlite3
Hashing Bun.hash(), Bun.password.*, WebCrypto node:crypto
Path resolution import.meta.dir, import.meta.path fileURLToPath dance
JSON5 Bun.JSON5.parse() / .stringify() json5 package
JSONL Bun.JSONL.parse() / .parseChunk() text.split("\n").map(JSON.parse)
String width Bun.stringWidth() get-east-asian-width, custom
Text wrapping Bun.wrapAnsi() custom ANSI-aware wrappers

Process execution

Prefer Bun Shell ($`cmd`) for simple commands:

import { $ } from "bun";

const result = await $`git status`.cwd(dir).quiet().nothrow();
if (result.exitCode === 0) {
	const text = result.text();
}

$`do-stuff ${tmpFile}`.quiet().nothrow(); // fire and forget

Methods: .quiet(), .nothrow(), .text(), .cwd(path).

Use Bun.spawn/Bun.spawnSync only for: long-running processes (LSP, kernels), streaming stdin/stdout/stderr (SSE, JSON-RPC), or process control (signals, kill, complex lifecycle).

When using pipe mode, cast the stream:

const child = Bun.spawn(["cmd"], { stdout: "pipe", stderr: "pipe" });
const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();

Node module imports

Always use namespace imports for node:fs, node:path, node:os:

import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
  • Async-only file → node:fs/promises.
  • Needs both sync and async → node:fs, then fs.promises.xxx for async.

File I/O

Prefer Bun:

const text = await Bun.file(path).text();
const data = await Bun.file(path).json();
await Bun.write(path, data); // auto-creates parent dirs

Use node:fs/promises for directory ops (fs.mkdir, fs.rm, fs.readdir) — Bun has no native directory APIs. Avoid sync APIs in async flows; use sync only when forced by a synchronous interface.

Anti-patterns:

  • existsSync/readFileSync/writeFileSync in async code → Bun.file() APIs.
  • mkdir(dirname(path), …) before Bun.write(path, …) → redundant; Bun.write handles it.
  • if (await file.exists()) { await file.json() } → two syscalls plus race. Use try-catch with isEnoent:
    import { isEnoent } from "@reactor/utils";
    try {
    	return await Bun.file(path).json();
    } catch (err) {
    	if (isEnoent(err)) return null;
    	throw err;
    }
  • Multiple Bun.file(path) handles for the same path (including across checkX/loadX helpers).
  • Buffer.from(await Bun.file(x).arrayBuffer())await fs.readFile(path).
  • Existence check + try-catch around the same read → drop the existence check.

Streams

Prefer centralized helpers:

import { readStream, readLines } from "./utils/stream";
const text = await readStream(child.stdout);
for await (const line of readLines(stream)) { /* ... */ }

Manual reader loops only when the protocol requires it (SSE, streaming JSON-RPC).

Misc

  • Sleep: await Bun.sleep(ms), never new Promise(r => setTimeout(r, ms)).
  • Password hashing: Bun.password.hash(pw, "bcrypt") / Bun.password.verify(pw, hash).
  • String width: Bun.stringWidth(text, { countAnsiEscapeCodes?: false }).
  • Wrapping: Bun.wrapAnsi(text, width, { wordWrap, hard, trim }).

Generated Files

NEVER edit packages/catalog/src/models.json directly. It is generated from upstream sources (models.dev, provider catalog discovery, OpenCode docs) by packages/catalog/scripts/generate-models.ts and the descriptors/resolvers in packages/catalog/src/provider-models/. Hand-edits get overwritten on the next regen.

To change an entry, fix the source:

  • Resolution rules / per-id overrides → relevant resolver in packages/catalog/src/provider-models/openai-compat.ts (e.g. createOpenCodeApiResolution's id-override map).
  • Provider catalog entries (default model, discovery factory/flags) → the CATALOG_PROVIDERS table in packages/catalog/src/provider-models/descriptors.ts.
  • Generator-level fixups (premium multipliers, codex pricing fallback, fallback models, post-processing) → packages/catalog/scripts/generate-models.ts.
  • Thinking metadata / generated policiespackages/catalog/src/model-thinking.ts (applyGeneratedModelPolicies); model-id classification (family/version parsing) lives in packages/catalog/src/identity/classify.ts.

Regenerate with bun run gen:models and commit models.json alongside the source change. Add a regression test against the resolver/descriptor, not the bundled JSON, so it survives upstream metadata shifts.

Logging

NEVER use console.log/error/warn in the coding-agent package — it corrupts TUI rendering. Use the centralized logger:

import { logger } from "@reactor/utils";

logger.error("MCP request failed", { url, method });
logger.warn("Theme file invalid, using fallback", { path });
logger.debug("LSP fallback triggered", { reason });

Logs go to ~/.reactor/logs/reactor.YYYY-MM-DD.log with automatic rotation.

TUI Sanitization

All text displayed in tool renderers must be sanitized. Raw content (file contents, error messages, tool output) breaks terminal rendering: tabs → visual holes, long lines → overflow, paths → leak home directory.

Rules:

  • Tabs → spaces via replaceTabs() (from @reactor/tui or ../tools/render-utils).
  • Truncate lines with truncateToWidth() / ui.truncate(). Use TRUNCATE_LENGTHS constants.
  • Shorten paths with shortenPath() (replaces home with ~).
  • Preview limits from PREVIEW_LIMITS. No ad-hoc numbers.

Apply to every render path, not just the happy one:

  • Success output (file previews, command output, search results).
  • Error messages — these often embed file content (e.g., patch failure messages include unmatched lines). If a message contains file content, it needs replaceTabs().
  • Diff content (added and removed).
  • Streaming previews.

Streaming tool previews

Tool-call previews can have multiple render paths. If you add preview-only fields or depend on partially streamed args, update every path — not only the final renderer. Streamed argument buffers decode into display args via decodeStreamedToolArgs / ToolArgsRevealController (modes/controllers/tool-args-reveal.ts); both the live event path and transcript rebuilds must go through them — never spread provider-parsed arguments next to a raw __partialJson (parsed args lag the stream by a throttled parse window).

For the bash tool specifically:

  • The pending preview may need raw partialJson, not just parsed arguments. Parsed args lag until a JSON object closes, which makes inline env assignments appear only at the end.
  • Preserve preview-only fields (e.g. __partialJson) through event-controller.ts, transcript rebuilds in ui-helpers.ts, and merged call/result rendering in tool-execution.ts. Missing one path causes inconsistent previews.
  • ToolExecutionComponent.#buildRenderContext() for bash must work even before a result exists — the renderer uses call args plus render context to show the command preview while streaming.
  • Verify both live streaming and rebuilt transcript paths after any bash preview change. A fix in one path does not fix the other.

Commands

  • NEVER commit unless asked.
  • Never use tsc/npx tsc — always bun check.

Testing Guidance

Test the contract the system exposes — not the easiest internal detail to assert.

  • Every new test must defend one concrete, externally observable contract: behavior, output shape, state transition, error mapping, or a regression-prone parsing boundary. If you cannot name the contract, do not add the test.
  • No placeholder tests, tautologies, or "the code ran" assertions (expect(true).toBe(true), bare not.toThrow(), non-empty string checks, length-grew checks, "prompt exists" checks without semantic assertion).
  • Prefer contract-level tests over implementation details. Avoid asserting internal helper wiring, field assignment, singleton identity, incidental ordering, prompt boilerplate, or passthrough option forwarding unless another component depends on that exact detail.
  • Don't duplicate coverage across abstraction levels. If an integration test already proves the behavior, drop the narrower unit test that restates it through mocks.
  • Tests must be full-suite safe, not just file-local safe. No long-lived file-wide mutations of Bun.*, process.platform, process.env, or Bun.env when a narrower seam exists. Prefer per-test vi.spyOn(...) with vi.restoreAllMocks() in afterEach. A test that passes alone but poisons later files is broken.
  • Never use mock.module(). Bun's mock.module() mutates the global module registry and leaks across files (oven-sh/bun#12823). Use spyOn on the imported module object instead. For pass deps, import the pass and spy on .run. For package deps, namespace-import and spy on the exported function.
  • For lifecycle/stateful code, prefer one test per invariant or transition over several tiny tests asserting one field each from the same transition.
  • For error handling, trigger the real failure path and assert the surfaced contract — don't instantiate error classes directly or inspect internal metadata.
  • Smoke tests are acceptable only when they catch a failure mode narrower tests would miss. "Package boots" or "command starts" alone is not enough.
  • Assert exact strings, ordering, and formatting only when downstream code parses or depends on the exact bytes. Otherwise assert semantic content.
  • Compile-time guarantees → type checks/type tests, not runtime placeholders.
  • Never source-grep. A test that reads an implementation file (.ts/.rs/build script) and asserts on its textexpect(src).toContain("someCall()"), .toMatch(/import .../), .not.toContain("oldName"), or "comment must say X" — is banned. It tests how code looks, not what it does: it breaks on harmless refactors (comment reflow, rename, import reorder) and passes while the behavior is broken. Assert the observable contract instead (run the code, check output/state/error), use the runtime smoke probe for wiring you cannot exercise in-process, and enforce structural invariants (no value-import of X, no self-import) with a type test or a lint/biome rule — never a string scan of the source. (Reading a file your code wrote — apply-patch result, generated bundle, temp fixture — and asserting on that output is fine; that is behavior, not a source grep.)
  • Don't add tests for tiny low-risk changes unless they protect a real contract or fix a regression-prone edge case.
  • Prefer focused package-local verification for the changed area.

Changelog

Location: packages/*/CHANGELOG.md (per package).

Format — sections under ## [Unreleased]:

  • ### Breaking Changes (first if present)
  • ### Added
  • ### Changed
  • ### Fixed
  • ### Removed

Rules:

  • New entries always go under ## [Unreleased].
  • Never modify already-released sections (e.g., ## [0.12.2]) — they are immutable.
  • Don't flag changelog section order or formatting in reviews or PRs — bun run release runs fix-changelogs which normalizes everything automatically.

Attribution:

  • Internal (from issues): Fixed foo bar ([#123](https://github.com/KNN-07/ReActor/issues/123)).
  • External contributions: Added feature X ([#456](https://github.com/KNN-07/ReActor/pull/456) by [@username](https://github.com/username)).

Releasing

  1. Ensure all changes since last release are in each affected package's [Unreleased] section.
  2. Run bun run release.

The script handles version bump, CHANGELOG finalization, commit, tag, publish, and adding new [Unreleased] sections.