spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1<!--2KHAELOR3File: docs/TOOL_PROTOCOL.md4Author: Simon-Pierre Boucher5Contact: contact@spboucher.ai6-->78# KHAELOR Tool Protocol — V1910> Phase 1 design document. Binding inputs: CLAUDE.md §10 (V1 Tools), §9 (Workspace), ADR-8 (tools), ADR-13 (workspace). Reference evidence: OPENCODE_ANALYSIS §6, OPENHANDS_ANALYSIS §4.11>12> This document is the implementation contract for `src/tools/`. Every schema, output format, error message shape, and truncation constant here is normative. Deviations require an ADR amendment.1314---1516## 0. Design rules (from ADR-8)17181. **Exactly seven tools:** `read` · `write` · `edit` · `grep` · `glob` · `bash` · `process`. No `git` tool in V1 — git flows through `bash` under permission rules.192. **≤5 parameters per tool.** Guidance lives in description text, never in parameter complexity (OpenCode §6.1).203. **Rich outputs, bounded size.** Every tool that can produce large output truncates head/tail with explicit omission markers and spills the full output to a file path the model can `read`/`grep` (convergent: Hermes §6.3, OpenCode §6.1, OpenHands §4.3).214. **OpenHands-quality failures.** Errors are actionable repair prose: line numbers of near-misses, "Maybe you meant …?" hints, post-edit snippets for self-verification. An error message is written for the model to *recover from*, never merely to report.225. **Tools never touch Node globals.** All file and process I/O goes through `Workspace` and services injected via `ToolContext` (ADR-13). A lint guard flags `node:fs`/`node:child_process` imports outside `src/workspace/` and approved shared modules.236. **Rendering lives in the TUI**, keyed by tool name + metadata — never on the tool or the event (OpenHands' `visualize` wart, OPENHANDS §5). This document specifies the rendering *contract* (what metadata each tool guarantees); `src/tui/tool-view/` implements it.2425Parameter naming is `snake_case` throughout (matches Anthropic tool-use conventions and the model's training distribution).2627---2829## 1. Shared infrastructure3031### 1.1 `ToolResult` envelope3233The envelope strictly separates **model-facing content** (the exact string placed in the `tool_result` block) from **UI-facing metadata** (diffs, counts, durations — never sent to the model, always available to the TUI and the event log).3435```ts36/**37 * KHAELOR38 * File: src/tools/types.ts (excerpt — normative shape)39 */4041export interface ToolResult {42 /** Exact text the model receives as tool_result content. */43 content: string;44 /** True → tool_result carries is_error: true. The content must be repair prose. */45 isError?: boolean;46 /** UI/log-facing. Never serialized into model context. */47 metadata?: ToolResultMetadata;48}4950export interface ToolResultMetadata {51 /** Collapsed one-liner shown in the conversation (see per-tool rendering contracts). */52 title: string;53 /** Unified diff for write/edit — powers instant `d` expansion and /diff. */54 diff?: string;55 additions?: number;56 deletions?: number;57 /** grep/glob counts. */58 matches?: number;59 files?: number;60 /** bash/process. */61 exitCode?: number | null;62 processId?: string;63 durationMs: number;64 truncation?: TruncationInfo;65 /** Free-form extras streamed during execution via ctx.progress(). */66 extra?: Record<string, unknown>;67}6869export interface TruncationInfo {70 originalBytes: number;71 originalLines: number;72 shownHeadLines: number;73 shownTailLines: number;74 omittedLines: number;75 /** Absolute path to the full output, if spilled. */76 spillPath?: string;77}78```7980### 1.2 `ToolDefinition` and the registry8182```ts83/**84 * KHAELOR85 * File: src/tools/registry.ts (excerpt — normative shape)86 */87import type { z } from "zod";8889export type ToolName =90 | "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process";9192export interface ToolDefinition<P = unknown> {93 readonly name: ToolName;94 /** Model-facing description — the exact text sent in the tools array. */95 readonly description: string;96 /** Zod schema is the source of truth; the Anthropic input_schema is derived from it. */97 readonly schema: z.ZodType<P>;98 /**99 * Maps a decoded input to the capability requests the permission system100 * evaluates BEFORE execute() is called. Pure function; no I/O beyond path101 * resolution against ctx.workspace.cwd(). See docs/PERMISSION_MODEL.md §2.102 */103 capabilities(input: P, ctx: ToolContext): CapabilityRequest[];104 /** Runs only after ToolApproved. Must respect ctx.signal. */105 execute(input: P, ctx: ToolContext): Promise<ToolResult>;106}107108export interface ToolContext {109 readonly sessionId: string;110 readonly callId: string; // Anthropic tool_use id111 readonly workspace: Workspace; // ADR-13 — the ONLY file/exec seam112 readonly signal: AbortSignal; // cancellation tree (ADR-11)113 readonly fileTimes: FileTimeRegistry; // §1.4114 readonly processes: ProcessManager; // §8115 /** Emit durable domain events (FileRead, FileModified, ProcessStarted, …). */116 emit(event: KhaelorEvent): void;117 /** Stream UI-facing progress metadata mid-execution (ephemeral events). */118 progress(meta: Record<string, unknown>): void;119 /** Spill oversized output; returns the absolute path (§1.3). */120 spill(label: string, content: string): Promise<string>;121}122123export class ToolRegistry {124 register(tool: ToolDefinition): void;125 /** Tools visible to the model this turn. Deny-rules hide tools (PERMISSION_MODEL §7). */126 available(policy: PermissionPolicy): ToolDefinition[];127 get(name: string): ToolDefinition | undefined;128 /** [{name, description, input_schema}] for the Anthropic request. */129 toAnthropicTools(policy: PermissionPolicy): AnthropicToolParam[];130}131```132133**Execution pipeline** (owned by the Executor, not by tools):134135```136tool_use block137 → schema.safeParse(input)138 ✗ → ToolResult{isError, content: repair prose} (never throws to kernel)139 → tool.capabilities(input, ctx)140 → permission evaluation (ToolRequested → …) (PERMISSION_MODEL §5)141 → emit ToolStarted142 → tool.execute(input, ctx) (AbortSignal-aware)143 → truncate/spill enforcement (defense in depth, §1.3)144 → emit ToolCompleted | ToolFailed (durable, carries ToolResult)145```146147Schema-decode failures produce model-facing repair prose, OpenCode-style:148`Invalid input for tool "edit": parameter "old_string" is required. Please rewrite the input so it satisfies the expected schema.`149150Cancellation (ADR-11): an aborted tool resolves to a synthetic result `[Tool execution cancelled by user]` with `isError: true`, keeping the `tool_use`/`tool_result` pairing protocol-valid. Cancelling the turn does **not** stop `process`-managed background processes.151152### 1.3 Truncation and spill (global rules)153154- Spill directory: `~/.khaelor/tool-output/<session-id>/` — files named `<tool>-<callId>.txt`. Session-scoped, garbage-collected with the session, global size cap 512 MB (oldest sessions pruned first).155- Truncation is **middle-out**: keep head + tail, insert one explicit marker line:156 `[... 1,842 lines omitted (58 KB). Full output: /Users/x/.khaelor/tool-output/s_ab12/bash-toolu_9.txt — read or grep that file for the rest.]`157- Per-tool limits are defined in each tool's section. The Executor enforces a hard backstop (64 KB model-facing content per result) even if a tool misbehaves.158- Truncation is always reported in `metadata.truncation` so the TUI can show `· truncated` and offer full-output expansion from the spill file.159160### 1.4 `FileTimeRegistry` — read-before-write and external-modification detection161162Session-scoped record of every file the agent has read or written:163164```ts165export interface FileStamp {166 path: string; // absolute, resolved167 mtimeMs: number;168 size: number;169 sha256: string; // of content as read/written170 at: number; // event timestamp171}172173export interface FileTimeRegistry {174 stamp(path: string, content: string): void; // called by read/write/edit on success175 get(path: string): FileStamp | undefined;176 /** "unread" | "clean" | "externally-modified" */177 check(path: string, currentContent: string): FileFreshness;178}179```180181Rules enforced by `write` and `edit` (§3, §4): existing files must have been read this session before modification, and must not have changed externally since. This protects user work (Absolute Rule #5) and is rebuilt on resume by replaying `FileRead`/`FileModified` events.182183---184185## 2. `read`186187### 2.1 Schema188189```json190{191 "name": "read",192 "description": "Read a file from the workspace. Returns the file content with line numbers, in the format 'LINE_NUMBER→CONTENT'. By default reads up to 2000 lines from the beginning. For larger files, use offset and limit to page through content — the output tells you the total line count and how to continue. Prefer reading only the region you need on large files. Binary files are detected and described instead of dumped. If the path is a directory, its entries are listed. File paths must be absolute or relative to the working directory.",193 "input_schema": {194 "type": "object",195 "properties": {196 "file_path": {197 "type": "string",198 "description": "Path to the file to read (absolute preferred)."199 },200 "offset": {201 "type": "integer",202 "description": "1-based line number to start reading from. Omit to start at line 1."203 },204 "limit": {205 "type": "integer",206 "description": "Maximum number of lines to return. Omit for the default of 2000."207 }208 },209 "required": ["file_path"]210 }211}212```213214### 2.2 Execution semantics2152161. Resolve path against `workspace.cwd()`. Relative paths are accepted but the resolved absolute path is echoed in output.2172. Guards, in order: existence (with near-miss suggestion, below) → directory (list entries, 2 levels, hidden-file counts) → size (files > 10 MB refuse with guidance to use `grep` or `offset`/`limit`) → binary sniff (extension list + non-printable ratio over a 4 KB sample; binary files return a one-line description: type, size — never bytes).2183. Read lines `[offset, offset+limit)`; default `limit` 2000; per-line truncation at 2000 chars (marker `… [line truncated]`); byte cap 50 KB per result — whichever limit hits first ends the page.2194. On success: `ctx.fileTimes.stamp(path, content)`; `emit(FileRead{path, lines})`.220221### 2.3 Success output (model-facing)222223```224/Users/x/dev/proj/src/kernel/agent.ts225 1→/**226 2→ * KHAELOR227 3→ * File: src/kernel/agent.ts228 ...229 2000→}230(Showing lines 1–2000 of 3417. Use offset=2001 to continue.)231```232233Line numbers are right-aligned, `→`-separated (stable format the model can quote back to `edit`).234235### 2.4 Errors236237- **Not found (with near-miss):**238 `File not found: /Users/x/dev/proj/src/kernal/agent.ts. Did you mean one of these? src/kernel/agent.ts, src/kernel/agent.test.ts` (up to 3 suggestions by name similarity within the repository index).239- **Relative-path confusion:** if a relative path fails but resolving it against `cwd` succeeds, the miss message says `Maybe you meant /Users/x/dev/proj/src/kernel/agent.ts?` (OpenHands §4.2 pattern).240- **Offset beyond EOF:** `Offset 5000 is beyond the end of the file (3417 lines). Use offset ≤ 3417.`241- **Too large / binary:** stated with size and the concrete alternative (`grep` for content search, `offset`/`limit` for regions).242243### 2.5 Rendering contract244245- Collapsed: `▸ Read src/kernel/agent.ts · lines 1–2000 of 3417`246- Expanded: syntax-highlighted excerpt (first/last N lines of what the model saw), path header, truncation badge. Metadata guaranteed: `title`, `extra.path`, `extra.lines`, `extra.totalLines`.247248---249250## 3. `write`251252### 3.1 Schema253254```json255{256 "name": "write",257 "description": "Write a complete file to the workspace, creating it (and parent directories) if needed, or fully replacing its content if it exists. To modify part of an existing file, use the edit tool instead — write replaces the whole file. You must have read an existing file with the read tool during this session before overwriting it; if the file changed on disk since you read it, the write is refused and you must re-read first. New source files in this project must begin with the mandatory KHAELOR author header (Author: Simon-Pierre Boucher, Contact: contact@spboucher.ai, File, Description).",258 "input_schema": {259 "type": "object",260 "properties": {261 "file_path": {262 "type": "string",263 "description": "Path of the file to write (absolute preferred)."264 },265 "content": {266 "type": "string",267 "description": "The complete new content of the file."268 }269 },270 "required": ["file_path"]271 }272}273```274275`content` is required in implementation (`required: ["file_path", "content"]` — listed here for clarity; the Zod schema marks both required).276277### 3.2 Execution semantics — overwrite protection278279For an **existing** file, in order:2802811. `fileTimes.get(path)` absent → refuse: read-before-write violation (error below).2822. Read current disk content; `fileTimes.check(path, current) === "externally-modified"` → refuse (error below). This catches the user (or another process) editing the file since the agent last saw it (Absolute Rule #5).2833. Compute unified diff old→new for `metadata.diff` and the permission request (PERMISSION_MODEL §6 shows the diff in the panel).284285For a **new** file: parent directories created; diff is against empty.286287Write mechanics (shared with `edit`): preserve existing line endings (CRLF detection) and BOM; atomic write — temp file in the same directory, `fsync`, `rename`, original mode bits preserved. On success: `fileTimes.stamp`, `emit(FileModified{path, additions, deletions, created})`.288289**Mandatory header reminder (Absolute Rule #0).** If the file is *new*, inside the project, with an extension in the header-required set (`.ts .tsx .js .mjs .cjs .sh .rs .md` per CLAUDE.md §2), and the content does not begin with the KHAELOR author header, the write **succeeds** but the result appends:290291```292NOTE: This new source file is missing the mandatory KHAELOR author header293(Author: Simon-Pierre Boucher · Contact: contact@spboucher.ai · File · Description).294Add it now — the header lint check fails the build without it.295```296297The hard gate is `scripts/check-headers` in CI; the tool-level reminder keeps the agent self-correcting in the same turn.298299### 3.3 Success output (model-facing)300301```302Wrote /Users/x/dev/proj/src/context/budget.ts (114 lines).303```304or for overwrite: `Replaced /Users/x/dev/proj/src/context/budget.ts (was 90 lines, now 114 lines).` — plus the header note when applicable.305306### 3.4 Errors307308- **Read-before-write:** `Refusing to overwrite /…/engine.ts: you have not read this file in this session. Read it first so you do not destroy existing content, then write or edit it.`309- **External modification:** `Refusing to overwrite /…/engine.ts: the file changed on disk after you last read it (content hash mismatch). Someone else may be editing it. Re-read the file and reapply your change.`310- **Path is a directory / permission / disk errors:** stated with the failing path and one concrete next step.311312### 3.5 Rendering contract313314- Collapsed (new): `▸ Write src/context/budget.ts · new file · 114 lines`315- Collapsed (overwrite): `▸ Write src/context/budget.ts · +47 −23`316- Expanded: unified diff (from `metadata.diff`), instant via key `d`. Metadata guaranteed: `title`, `diff`, `additions`, `deletions`, `extra.created`.317318---319320## 4. `edit`321322The most important tool (CLAUDE.md §10). Reference: OpenCode's nine-strategy replacer cascade (OPENCODE §6.2, credited there to Cline/gemini-cli lineage) layered with OpenHands' failure UX (OPENHANDS §4.2).323324### 4.1 Schema325326```json327{328 "name": "edit",329 "description": "Replace an exact string in a file. Provide the text to find in old_string and its replacement in new_string. old_string must uniquely identify one location: include 3–5 lines of surrounding context exactly as it appears in the file, including whitespace and indentation. If old_string matches multiple locations the edit fails and reports the line numbers — add more context and retry, or set replace_all to true to change every exact occurrence (useful for renames). The tool tolerates minor whitespace drift but will refuse ambiguous or disproportionate fuzzy matches. You must read the file during this session before editing it. On success you get a snippet of the edited region — review it instead of re-reading the file.",330 "input_schema": {331 "type": "object",332 "properties": {333 "file_path": {334 "type": "string",335 "description": "Path of the file to edit (absolute preferred)."336 },337 "old_string": {338 "type": "string",339 "description": "The exact existing text to replace, with enough surrounding context to be unique in the file."340 },341 "new_string": {342 "type": "string",343 "description": "The replacement text. Must differ from old_string."344 },345 "replace_all": {346 "type": "boolean",347 "description": "Replace every exact occurrence of old_string instead of requiring uniqueness. Default false."348 }349 },350 "required": ["file_path", "old_string", "new_string"]351 }352}353```354355### 4.2 Preconditions3563571. File must exist (near-miss suggestion as in `read`). Empty `old_string` on an existing file → error directing to `write` for full replacement or to include real context.3582. `old_string === new_string` → error: `old_string and new_string are identical — there is nothing to change.`3593. Read-before-edit and external-modification checks, identical to `write` §3.2 (same `FileTimeRegistry`, same error prose with "Re-read the file").3604. Per-file mutex: concurrent edits to one file are serialized (OpenCode's per-file semaphore).361362### 4.3 The replacer cascade363364Matching runs against the file content normalized to `\n` (original line endings restored on write; BOM split off and rejoined). Strategies run **in order; first strategy that produces at least one match wins** and later strategies never run. KHAELOR orders cheap normalizations before fuzzy block matching — a deliberate divergence from OpenCode (which runs block-anchor third): a near-exact whitespace match must never lose to a fuzzy anchor match.365366| # | Strategy | Matches when | Guards specific to it |367|---|----------|--------------|----------------------|368| 1 | **Exact** | `old_string` appears verbatim. | — |369| 2 | **Line-trimmed** | Line-by-line comparison with each line trimmed of leading/trailing whitespace; line count must match. | Reconstructs the true (untrimmed) span from the file for replacement. |370| 3 | **Whitespace-normalized** | All runs of whitespace collapsed to single spaces on both sides. | Only fires when old_string has ≥1 non-whitespace token. |371| 4 | **Indentation-flexible** | Common leading indentation stripped from every line of `old_string`; body then compared line-trimmed-right. Handles the model copying a block at the wrong indent depth. | Re-applies the *file's* indentation to `new_string` lines (indentation preservation). |372| 5 | **Escape-normalized** | `old_string` with literal `\n`, `\t`, `\"`, `\'`, `\\` unescaped matches (LLMs over-escape). | Applies the same unescaping to `new_string`. |373| 6 | **Trimmed-boundary** | `old_string.trim()` matches; surrounding whitespace in the file preserved. | — |374| 7 | **Block-anchor** | `old_string` has ≥3 lines; first and last lines match exactly (trimmed) as anchors; middle lines matched by Levenshtein similarity ≥ 0.65; candidate block size within ±25% of `old_string`'s. Best-scoring candidate of several wins. | Disproportionate-match guard (§4.4). |375| 8 | **Context-aware** | Anchor lines match and ≥50% of middle lines match trimmed. Last-resort fuzzy. | Disproportionate-match guard (§4.4). |376| 9 | **Multi-occurrence** | `replace_all: true` only — all *exact* occurrences replaced. Fuzzy strategies are never combined with `replace_all`. | — |377378### 4.4 Uniqueness and ambiguity guards379380- **Uniqueness (strategies 1–8):** if the winning strategy yields more than one distinct match position and `replace_all` is false → ambiguity error citing line numbers (§4.6). The cascade does not silently pick the first.381- **Disproportionate-match guard (7–8):** a fuzzy candidate whose character length exceeds `max(3 × old_string.length, old_string.length + 1000)` is refused: the model gave too little context for the match to be trustworthy. Error tells it to re-read and provide the full exact text.382- **`replace_all` semantics:** strategy 9 only (exact matches). Zero exact matches → the not-found error (fuzzy hints included) — never a fuzzy mass-replace. The result reports the occurrence count.383384### 4.5 Write-back, diff, events385386Atomic write identical to §3.2 (temp + fsync + rename; CRLF/BOM/mode preserved). Unified diff computed old→new and attached to `metadata.diff` **and** to the permission request metadata (the permission panel shows the actual diff — OpenCode §8.3). `fileTimes.stamp`; `emit(FileModified{path, additions, deletions, strategy})`. Per-file undo history (last 10 versions, in-memory + spill) is kept for the future `undo` path (OpenHands' `FileHistoryManager`) — not model-exposed in V1.387388### 4.6 Output design389390**Success (model-facing):**391392```393Edited /Users/x/dev/proj/src/context/engine.ts (1 replacement, matched exactly).394Snippet of the edited region (lines 141–152):395 141→ compress(state: SessionState): CompactionPlan {396 142→ const budget = this.budget.usable(state.model);397 ...398 152→ }399Review the changes. Edit the file again if the result is not what you intended.400```401402Snippet = edited region ± 4 lines, with real line numbers — the model self-verifies without a re-read (OpenHands §4.2). When a fuzzy strategy fired, the parenthetical names it (`matched with whitespace normalization`) so the model knows its quoted text was imprecise. `replace_all` success: `Edited … (7 replacements).` with a snippet of the first region.403404**Not found:**405406```407No replacement was performed: old_string was not found in /…/engine.ts.408Closest near-miss is at lines 141–149 (differs in whitespace on line 143:409expected " const budget =", file has "\tconst budget ="). Read that region410and provide old_string exactly as it appears in the file.411```412413Near-miss = best block-anchor candidate below threshold, when one exists; otherwise the plain not-found line plus a hint to `read` the file. Never dump the whole file.414415**Ambiguous:**416417```418No replacement was performed: old_string matches 3 locations in /…/engine.ts419(lines 87, 141, 209). Add more surrounding lines to old_string so it uniquely420identifies one location, or set replace_all to true to change all 3.421```422423**Stale file:** identical prose to §3.4 external-modification.424425### 4.7 Rendering contract426427- Collapsed: `▸ Edit src/context/engine.ts · +31 −12`428- Expanded (`d`): the unified diff, syntax highlighted; strategy badge when non-exact. Metadata guaranteed: `title`, `diff`, `additions`, `deletions`, `extra.strategy`, `extra.replacements`.429- Completion line in conversation: `✓ src/context/engine.ts +31 −12` (CLAUDE.md §14).430431---432433## 5. `grep`434435### 5.1 Schema436437```json438{439 "name": "grep",440 "description": "Fast content search across the repository using ripgrep. pattern is a regular expression (Rust regex syntax; escape literal dots, parens, brackets). Results are grouped by file as 'line_number: line text', files ordered by most recently modified. At most 100 matching lines are returned — if truncated, narrow the pattern or scope with path/include. Respects .gitignore. Use this to locate code; use read to view full context around a match.",441 "input_schema": {442 "type": "object",443 "properties": {444 "pattern": {445 "type": "string",446 "description": "Regular expression to search for."447 },448 "path": {449 "type": "string",450 "description": "Directory or file to search in. Defaults to the working directory."451 },452 "include": {453 "type": "string",454 "description": "Glob filter for file names, e.g. \"*.ts\" or \"src/**/*.py\"."455 }456 },457 "required": ["pattern"]458 }459}460```461462### 5.2 Execution semantics463464Thin wrapper over bundled ripgrep (ship the binary with the npm package; system `rg` fallback with logged warning — OpenHands §4.4). Executed via `workspace.exec`. Flags: smart-case, `--hidden` off, honors `.gitignore` + `.khaelorignore`. Hard limits: **100 matching lines**, 250 chars per line (marker `…`), 10 s timeout. Invalid regex is caught before execution.465466### 5.3 Success output (model-facing)467468```46914 matches in 5 files for "ContextEngine":470471src/context/engine.ts472 12: export class ContextEngine {473 87: // ContextEngine owns compaction triggers474src/kernel/agent.ts475 41: constructor(private context: ContextEngine) {}476...477```478479Zero matches: `No matches for "ContextEnginee" in /…/proj. Check the regex (did you mean "ContextEngine"?) or broaden the scope.` — the near-miss hint appears only when a case-insensitive or edit-distance-1 variant would have matched (cheap re-probe).480481Truncated: header becomes `Showing first 100 of 412 matching lines (…)` and a final line: `[Results truncated. Full results: <spillPath> — or use a more specific pattern, path, or include filter.]`482483### 5.4 Rendering contract484485- Collapsed: `▸ Search "ContextEngine" · 14 matches in 5 files`486- Expanded: grouped match list, highlighted match spans, click/enter opens `read` view at line. Metadata guaranteed: `title`, `matches`, `files`, `truncation?`.487488---489490## 6. `glob`491492### 6.1 Schema493494```json495{496 "name": "glob",497 "description": "Find files by name pattern, e.g. \"**/*.ts\" or \"src/**/config.*\". Returns matching file paths ordered by most recently modified, at most 100. Respects .gitignore and .khaelorignore. Use this to discover file layout; use grep to search file contents.",498 "input_schema": {499 "type": "object",500 "properties": {501 "pattern": {502 "type": "string",503 "description": "Glob pattern to match file paths against."504 },505 "path": {506 "type": "string",507 "description": "Directory to search in. Defaults to the working directory."508 }509 },510 "required": ["pattern"]511 }512}513```514515### 6.2 Execution semantics516517Implemented over the repository index's file list when warm (fast path), else `rg --files` + glob filter. Ignore rules: `.gitignore`, `.khaelorignore`, plus built-in noise (`node_modules/`, `.git/`, `dist/` unless the pattern explicitly targets them). Limit 100 paths, mtime-desc.518519### 6.3 Output520521```52223 files match "src/**/*.ts" (newest first):523src/context/engine.ts524src/kernel/agent.ts525...526```527528Zero: `No files match "src/**/*.tsx" under /…/proj. Nearest existing extension: .ts (23 files).` Truncated: `[Showing 100 of 312. Full list: <spillPath> — or narrow the pattern.]`529530### 6.4 Rendering contract531532- Collapsed: `▸ Glob src/**/*.ts · 23 files`533- Expanded: path list with mtime badges. Metadata: `title`, `files`, `truncation?`.534535---536537## 7. `bash`538539### 7.1 Schema540541```json542{543 "name": "bash",544 "description": "Run a shell command in the workspace and wait for it to finish. Use this for short-lived commands: builds, tests, git, package scripts, file operations. Do NOT use it for long-running processes such as dev servers, watchers, or REPLs — start those with the process tool so they run in the background while you keep working. Commands run in a non-interactive shell from the working directory (use the workdir parameter instead of 'cd'). stdout and stderr are returned interleaved with the exit code. If a command is still running when the time ceiling is reached, it is moved to the background process manager and you get its process id plus the output so far. Quote paths containing spaces.",545 "input_schema": {546 "type": "object",547 "properties": {548 "command": {549 "type": "string",550 "description": "The shell command to execute."551 },552 "timeout_ms": {553 "type": "integer",554 "description": "Time budget in milliseconds before the command is moved to the background. Default 120000, maximum 300000."555 },556 "workdir": {557 "type": "string",558 "description": "Working directory for the command. Defaults to the project working directory. Use this instead of 'cd'."559 }560 },561 "required": ["command"]562 }563}564```565566### 7.2 Execution semantics — and the `process` boundary567568- Runs via `workspace.exec` in its **own process group** (`detached: true`, kill by `-pgid` — mini-SWE's kill hygiene) with a PTY when available (correct output for tools that sniff TTY), `TERM=dumb`-safe fallback otherwise. Non-interactive: stdin closed.569- Environment: inherited, minus KHAELOR-internal variables; `NO_COLOR` unset (color stripped at render, kept in spill).570- Permission evaluation happens on the parsed command **before** execution (capabilities: `process.execute`, plus derived `network.access` / `git.modify` / `file.write.outsideProject` — PERMISSION_MODEL §2–3).571- **Hard timeout ceiling → redirect, not kill (ADR-8, Hermes §6.1).** At `timeout_ms` (default 120 s, cap 300 s), the still-running command is *adopted by the ProcessManager*: it keeps its process group, gains a process id, its output streams into the ring buffer + spill log, and `bash` returns immediately with the output so far. Nothing is silently killed; nothing blocks forever. (OpenHands' `exit_code=-1` soft-timeout *convention* is explicitly rejected as primary mechanism — the redirect is structural, not a convention the model must learn.)572- User interrupt (Esc) kills the process group of a foreground `bash` command (it was meant to be short-lived) — unlike `process`-managed processes, which survive.573- Events: `ProcessStarted` / `ProcessExited` (durable); `ProcessOutput` chunks are ephemeral, with the completed result durable in `ToolCompleted`.574575### 7.3 Output and truncation576577```578$ npm test579> proj@0.3.1 test580> vitest run581 ✓ src/context/engine.test.ts (14 tests)582...583[exit code 0 · 3.2s · cwd /Users/x/dev/proj]584```585586Limits: 400 lines / 30 KB, middle-out (head 250 / tail 150) with the standard spill marker (§1.3). Non-zero exit is **not** `isError` at the envelope level — the model must see failing output as normal observation (`[exit code 1 · …]`); `isError` is reserved for KHAELOR-level failures (spawn failure, permission denial, cancellation).587588Redirect result:589590```591Command still running after 120s — moved to background as process p4.592Output so far:593...594Use process {"action":"read","id":"p4"} to see new output, or {"action":"stop","id":"p4"} to stop it.595```596597### 7.4 Errors598599- Spawn failure: `Command failed to start: npmm: command not found. Did you mean "npm"?` (PATH near-miss probe, best-effort).600- Permission denial: model-facing prose from PERMISSION_MODEL §5.4 — a course correction, not a dead end.601602### 7.5 Rendering contract603604- Collapsed: `▸ Run npm test · exit 0 · 3.2s` (failure: `▸ Run npm test · exit 1 · 4.1s` with failure styling + symbol, never color alone).605- Expanded: scrollable terminal-styled output (ANSI-rendered from spill), exit code, duration, cwd. While running, the status line shows `● Running npm test · 12s`. Metadata: `title`, `exitCode`, `durationMs`, `truncation?`, `extra.cwd`, `processId?` (when redirected).606607---608609## 8. `process`610611Model-facing background process manager (ADR-8 — OpenCode's biggest tool-level gap, "do not inherit the omission"). One tool, action-discriminated, 5 parameters.612613### 8.1 Schema614615```json616{617 "name": "process",618 "description": "Manage long-running background processes: dev servers, watchers, REPLs, anything that should keep running while you continue working. Actions: 'start' launches a command in the background and returns its process id immediately; 'list' shows all managed processes with status; 'read' returns output produced since your last read (or from line 'offset' if given); 'write' sends text to the process's stdin (include \\n to submit a line); 'stop' terminates the process and its children. Background processes keep running while you edit files and run other commands — start a server, keep working, then read its output to check on it. They survive user interruptions but end when the session ends. Do not use this for short commands; use bash.",619 "input_schema": {620 "type": "object",621 "properties": {622 "action": {623 "type": "string",624 "enum": ["start", "list", "read", "write", "stop"],625 "description": "The operation to perform."626 },627 "command": {628 "type": "string",629 "description": "Shell command to launch. Required for 'start'."630 },631 "id": {632 "type": "string",633 "description": "Process id, e.g. \"p3\". Required for 'read', 'write', 'stop'."634 },635 "input": {636 "type": "string",637 "description": "Text to send to stdin. Required for 'write'. End with \\n to submit a line."638 },639 "offset": {640 "type": "integer",641 "description": "For 'read': 1-based output line to read from, instead of 'new output since last read'."642 }643 },644 "required": ["action"]645 }646}647```648649### 8.2 ProcessManager lifecycle650651```ts652export interface ManagedProcess {653 id: string; // "p1", "p2" … session-scoped handle — NOT the OS pid654 pid: number; // OS pid, with start-time recorded (PID-reuse guard, Hermes §6.2)655 command: string;656 status: "running" | "exited" | "stopped" | "failed";657 exitCode: number | null;658 startedAt: number;659 cwd: string;660 logPath: string; // full output spill: ~/.khaelor/process-logs/<session>/<id>.log661}662663export interface ProcessManager {664 start(command: string, cwd: string): Promise<ManagedProcess>;665 list(): ManagedProcess[];666 read(id: string, opts?: { offset?: number }): ProcessRead;667 write(id: string, input: string): Promise<void>;668 stop(id: string): Promise<{ exitCode: number | null }>; // SIGTERM → 3s grace → SIGKILL, whole process group669 adopt(child: SpawnedChild): ManagedProcess; // bash timeout redirect (§7.2)670 stopAll(): Promise<void>; // session end671}672```673674- **PTY-backed** where available (dev servers behave correctly), own process group.675- **Output ring buffer with spill:** per-process in-memory ring of 10,000 lines / 2 MB; **all** output is simultaneously appended to `logPath` (never truncated, session GC + 512 MB cap shared with §1.3). Ring overflow loses nothing — old lines are already on disk, and `read` with `offset` reads from the log.676- **Read cursor:** each process keeps a per-session read cursor; `read` without `offset` returns lines since the cursor and advances it (Hermes' poll/log pagination). Read page cap: 300 lines / 20 KB with standard spill marker pointing at `logPath`.677- Exit is detected and recorded (`ProcessExited` durable event) even if the model never reads again; `list` and the status bar reflect it. Crash of KHAELOR itself: log files survive; managed processes are killed on graceful shutdown, orphan-checked on restart via pid+start-time.678- User interruption (Esc) does **not** touch managed processes (ADR-11). `stopAll` runs on session end after user-visible notice.679680### 8.3 Outputs (model-facing)681682**start:**683```684Started p3 (pid 41232): npm run dev685cwd /Users/x/dev/proj · log /Users/x/.khaelor/process-logs/s_ab12/p3.log686First output (waited up to 2s):687 VITE v5.4.1 ready in 431 ms688 ➜ Local: http://localhost:5173/689Use process {"action":"read","id":"p3"} for new output.690```691(`start` waits up to 2 s for initial output — enough to catch instant failures like port-in-use without blocking on healthy servers. A command that exits within the window returns its status inline: `Process p3 exited immediately with code 1: …`.)692693**list:**694```695PROCESSES696p3 npm run dev running 04:32 pid 41232697p4 pytest -x running 00:18 pid 41390698p1 npm test exited code 0699```700701**read:** `Output of p3 since last read (lines 84–131 of 131):` + lines; nothing new → `No new output from p3 since last read (still running, 131 lines total). Use offset to re-read earlier output.`702703**write:** `Sent 14 bytes to p3 stdin.` (followed by an automatic 500 ms read of any response, appended as `Output:` — saves the model a round trip).704705**stop:** `Stopped p3 (npm run dev) · exit code null (SIGTERM) · ran 06:12. Full log: /…/p3.log`706707### 8.4 Errors708709- Unknown id: `No process "p7". Active: p3 (npm run dev, running), p4 (pytest, running). Use {"action":"list"} to see all.`710- `write`/`read` on exited process: states the exit code and points at the log path.711- Missing conditional params (e.g. `start` without `command`): schema-level repair prose (§1.2).712713### 8.5 Rendering contract714715- Collapsed: `▸ Process start npm run dev · p3 running` / `▸ Process read p3 · 47 new lines` / `▸ Process stop p3 · exited`716- Expanded: per-action — start shows first output; read shows the page; list renders the table.717- **Status bar integration:** running process count (`⚙ 2`) appears in the status bar; `/processes` opens the full panel with live tails. Metadata: `title`, `processId`, `exitCode?`, `extra.status`, `extra.newLines?`.718719---720721## 9. Conformance checklist (tests to ship with `src/tools/`)722723- [ ] Every schema round-trips: Zod → JSON Schema → Anthropic `input_schema`; ≤5 params each.724- [ ] Golden tests for every model-facing output and error string in this document.725- [ ] Edit cascade: one fixture per strategy (1–9), plus ambiguity, disproportionate-guard, CRLF, BOM, replace_all-zero-match.726- [ ] Read-before-write and external-modification enforcement for `write` and `edit`; registry rebuild on resume.727- [ ] Truncation: head/tail boundaries exact; spill files created; 64 KB executor backstop.728- [ ] `bash` timeout redirect: command survives, appears in `process list`, output continuous across the seam.729- [ ] Process-group kill: `stop` reaps grandchildren; PID-reuse guard.730- [ ] No `node:fs`/`node:child_process` imports outside `src/workspace/` (lint guard, ADR-13).731- [ ] Header reminder fires for new headerless source files; not for `.json`/vendored paths.732733---734735*Author: Simon-Pierre Boucher · contact@spboucher.ai*736