KHAELOR Tool Protocol — V1
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.
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.
0. Design rules (from ADR-8)
- Exactly seven tools:
read·write·edit·grep·glob·bash·process. Nogittool in V1 — git flows throughbashunder permission rules. - ≤5 parameters per tool. Guidance lives in description text, never in parameter complexity (OpenCode §6.1).
- 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). - 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.
- Tools never touch Node globals. All file and process I/O goes through
Workspaceand services injected viaToolContext(ADR-13). A lint guard flagsnode:fs/node:child_processimports outsidesrc/workspace/and approved shared modules. - Rendering lives in the TUI, keyed by tool name + metadata — never on the tool or the event (OpenHands'
visualizewart, OPENHANDS §5). This document specifies the rendering contract (what metadata each tool guarantees);src/tui/tool-view/implements it.
Parameter naming is snake_case throughout (matches Anthropic tool-use conventions and the model's training distribution).
1. Shared infrastructure
1.1 ToolResult envelope
The 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).
/**
* KHAELOR
* File: src/tools/types.ts (excerpt — normative shape)
*/
export interface ToolResult {
/** Exact text the model receives as tool_result content. */
content: string;
/** True → tool_result carries is_error: true. The content must be repair prose. */
isError?: boolean;
/** UI/log-facing. Never serialized into model context. */
metadata?: ToolResultMetadata;
}
export interface ToolResultMetadata {
/** Collapsed one-liner shown in the conversation (see per-tool rendering contracts). */
title: string;
/** Unified diff for write/edit — powers instant `d` expansion and /diff. */
diff?: string;
additions?: number;
deletions?: number;
/** grep/glob counts. */
matches?: number;
files?: number;
/** bash/process. */
exitCode?: number | null;
processId?: string;
durationMs: number;
truncation?: TruncationInfo;
/** Free-form extras streamed during execution via ctx.progress(). */
extra?: Record<string, unknown>;
}
export interface TruncationInfo {
originalBytes: number;
originalLines: number;
shownHeadLines: number;
shownTailLines: number;
omittedLines: number;
/** Absolute path to the full output, if spilled. */
spillPath?: string;
} 1.2 ToolDefinition and the registry
/**
* KHAELOR
* File: src/tools/registry.ts (excerpt — normative shape)
*/
import type { z } from "zod";
export type ToolName =
| "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process";
export interface ToolDefinition<P = unknown> {
readonly name: ToolName;
/** Model-facing description — the exact text sent in the tools array. */
readonly description: string;
/** Zod schema is the source of truth; the Anthropic input_schema is derived from it. */
readonly schema: z.ZodType<P>;
/**
* Maps a decoded input to the capability requests the permission system
* evaluates BEFORE execute() is called. Pure function; no I/O beyond path
* resolution against ctx.workspace.cwd(). See docs/PERMISSION_MODEL.md §2.
*/
capabilities(input: P, ctx: ToolContext): CapabilityRequest[];
/** Runs only after ToolApproved. Must respect ctx.signal. */
execute(input: P, ctx: ToolContext): Promise<ToolResult>;
}
export interface ToolContext {
readonly sessionId: string;
readonly callId: string; // Anthropic tool_use id
readonly workspace: Workspace; // ADR-13 — the ONLY file/exec seam
readonly signal: AbortSignal; // cancellation tree (ADR-11)
readonly fileTimes: FileTimeRegistry; // §1.4
readonly processes: ProcessManager; // §8
/** Emit durable domain events (FileRead, FileModified, ProcessStarted, …). */
emit(event: KhaelorEvent): void;
/** Stream UI-facing progress metadata mid-execution (ephemeral events). */
progress(meta: Record<string, unknown>): void;
/** Spill oversized output; returns the absolute path (§1.3). */
spill(label: string, content: string): Promise<string>;
}
export class ToolRegistry {
register(tool: ToolDefinition): void;
/** Tools visible to the model this turn. Deny-rules hide tools (PERMISSION_MODEL §7). */
available(policy: PermissionPolicy): ToolDefinition[];
get(name: string): ToolDefinition | undefined;
/** [{name, description, input_schema}] for the Anthropic request. */
toAnthropicTools(policy: PermissionPolicy): AnthropicToolParam[];
}Execution pipeline (owned by the Executor, not by tools):
tool_use block
→ schema.safeParse(input)
✗ → ToolResult{isError, content: repair prose} (never throws to kernel)
→ tool.capabilities(input, ctx)
→ permission evaluation (ToolRequested → …) (PERMISSION_MODEL §5)
→ emit ToolStarted
→ tool.execute(input, ctx) (AbortSignal-aware)
→ truncate/spill enforcement (defense in depth, §1.3)
→ emit ToolCompleted | ToolFailed (durable, carries ToolResult)Schema-decode failures produce model-facing repair prose, OpenCode-style:
Invalid input for tool "edit": parameter "old_string" is required. Please rewrite the input so it satisfies the expected schema.
Cancellation (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.
1.3 Truncation and spill (global rules)
- 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). - Truncation is middle-out: keep head + tail, insert one explicit marker line:
[... 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.] - 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.
- Truncation is always reported in
metadata.truncationso the TUI can show· truncatedand offer full-output expansion from the spill file.
1.4 FileTimeRegistry — read-before-write and external-modification detection
Session-scoped record of every file the agent has read or written:
export interface FileStamp {
path: string; // absolute, resolved
mtimeMs: number;
size: number;
sha256: string; // of content as read/written
at: number; // event timestamp
}
export interface FileTimeRegistry {
stamp(path: string, content: string): void; // called by read/write/edit on success
get(path: string): FileStamp | undefined;
/** "unread" | "clean" | "externally-modified" */
check(path: string, currentContent: string): FileFreshness;
}Rules 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.
2. read
2.1 Schema
{
"name": "read",
"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.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to read (absolute preferred)."
},
"offset": {
"type": "integer",
"description": "1-based line number to start reading from. Omit to start at line 1."
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return. Omit for the default of 2000."
}
},
"required": ["file_path"]
}
}2.2 Execution semantics
- Resolve path against
workspace.cwd(). Relative paths are accepted but the resolved absolute path is echoed in output. - 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
greporoffset/limit) → binary sniff (extension list + non-printable ratio over a 4 KB sample; binary files return a one-line description: type, size — never bytes). - Read lines
[offset, offset+limit); defaultlimit2000; per-line truncation at 2000 chars (marker… [line truncated]); byte cap 50 KB per result — whichever limit hits first ends the page. - On success:
ctx.fileTimes.stamp(path, content);emit(FileRead{path, lines}).
2.3 Success output (model-facing)
/Users/x/dev/proj/src/kernel/agent.ts
1→/**
2→ * KHAELOR
3→ * File: src/kernel/agent.ts
...
2000→}
(Showing lines 1–2000 of 3417. Use offset=2001 to continue.)Line numbers are right-aligned, →-separated (stable format the model can quote back to edit).
2.4 Errors
- Not found (with near-miss):
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). - Relative-path confusion: if a relative path fails but resolving it against
cwdsucceeds, the miss message saysMaybe you meant /Users/x/dev/proj/src/kernel/agent.ts?(OpenHands §4.2 pattern). - Offset beyond EOF:
Offset 5000 is beyond the end of the file (3417 lines). Use offset ≤ 3417. - Too large / binary: stated with size and the concrete alternative (
grepfor content search,offset/limitfor regions).
2.5 Rendering contract
- Collapsed:
▸ Read src/kernel/agent.ts · lines 1–2000 of 3417 - 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.
3. write
3.1 Schema
{
"name": "write",
"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).",
"input_schema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path of the file to write (absolute preferred)."
},
"content": {
"type": "string",
"description": "The complete new content of the file."
}
},
"required": ["file_path"]
}
}content is required in implementation (required: ["file_path", "content"] — listed here for clarity; the Zod schema marks both required).
3.2 Execution semantics — overwrite protection
For an existing file, in order:
fileTimes.get(path)absent → refuse: read-before-write violation (error below).- 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). - Compute unified diff old→new for
metadata.diffand the permission request (PERMISSION_MODEL §6 shows the diff in the panel).
For a new file: parent directories created; diff is against empty.
Write 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}).
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:
NOTE: This new source file is missing the mandatory KHAELOR author header
(Author: Simon-Pierre Boucher · Contact: contact@spboucher.ai · File · Description).
Add it now — the header lint check fails the build without it.The hard gate is scripts/check-headers in CI; the tool-level reminder keeps the agent self-correcting in the same turn.
3.3 Success output (model-facing)
Wrote /Users/x/dev/proj/src/context/budget.ts (114 lines).or for overwrite: Replaced /Users/x/dev/proj/src/context/budget.ts (was 90 lines, now 114 lines). — plus the header note when applicable.
3.4 Errors
- 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. - 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. - Path is a directory / permission / disk errors: stated with the failing path and one concrete next step.
3.5 Rendering contract
- Collapsed (new):
▸ Write src/context/budget.ts · new file · 114 lines - Collapsed (overwrite):
▸ Write src/context/budget.ts · +47 −23 - Expanded: unified diff (from
metadata.diff), instant via keyd. Metadata guaranteed:title,diff,additions,deletions,extra.created.
4. edit
The 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).
4.1 Schema
{
"name": "edit",
"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.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path of the file to edit (absolute preferred)."
},
"old_string": {
"type": "string",
"description": "The exact existing text to replace, with enough surrounding context to be unique in the file."
},
"new_string": {
"type": "string",
"description": "The replacement text. Must differ from old_string."
},
"replace_all": {
"type": "boolean",
"description": "Replace every exact occurrence of old_string instead of requiring uniqueness. Default false."
}
},
"required": ["file_path", "old_string", "new_string"]
}
}4.2 Preconditions
- File must exist (near-miss suggestion as in
read). Emptyold_stringon an existing file → error directing towritefor full replacement or to include real context. old_string === new_string→ error:old_string and new_string are identical — there is nothing to change.- Read-before-edit and external-modification checks, identical to
write§3.2 (sameFileTimeRegistry, same error prose with "Re-read the file"). - Per-file mutex: concurrent edits to one file are serialized (OpenCode's per-file semaphore).
4.3 The replacer cascade
Matching 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.
| # | Strategy | Matches when | Guards specific to it |
|---|---|---|---|
| 1 | Exact | old_string appears verbatim. |
— |
| 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. |
| 3 | Whitespace-normalized | All runs of whitespace collapsed to single spaces on both sides. | Only fires when old_string has ≥1 non-whitespace token. |
| 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). |
| 5 | Escape-normalized | old_string with literal \n, \t, \", \', \\ unescaped matches (LLMs over-escape). |
Applies the same unescaping to new_string. |
| 6 | Trimmed-boundary | old_string.trim() matches; surrounding whitespace in the file preserved. |
— |
| 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). |
| 8 | Context-aware | Anchor lines match and ≥50% of middle lines match trimmed. Last-resort fuzzy. | Disproportionate-match guard (§4.4). |
| 9 | Multi-occurrence | replace_all: true only — all exact occurrences replaced. Fuzzy strategies are never combined with replace_all. |
— |
4.4 Uniqueness and ambiguity guards
- Uniqueness (strategies 1–8): if the winning strategy yields more than one distinct match position and
replace_allis false → ambiguity error citing line numbers (§4.6). The cascade does not silently pick the first. - 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. replace_allsemantics: 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.
4.5 Write-back, diff, events
Atomic 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.
4.6 Output design
Success (model-facing):
Edited /Users/x/dev/proj/src/context/engine.ts (1 replacement, matched exactly).
Snippet of the edited region (lines 141–152):
141→ compress(state: SessionState): CompactionPlan {
142→ const budget = this.budget.usable(state.model);
...
152→ }
Review the changes. Edit the file again if the result is not what you intended.Snippet = 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.
Not found:
No replacement was performed: old_string was not found in /…/engine.ts.
Closest near-miss is at lines 141–149 (differs in whitespace on line 143:
expected " const budget =", file has "\tconst budget ="). Read that region
and provide old_string exactly as it appears in the file.Near-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.
Ambiguous:
No replacement was performed: old_string matches 3 locations in /…/engine.ts
(lines 87, 141, 209). Add more surrounding lines to old_string so it uniquely
identifies one location, or set replace_all to true to change all 3.Stale file: identical prose to §3.4 external-modification.
4.7 Rendering contract
- Collapsed:
▸ Edit src/context/engine.ts · +31 −12 - Expanded (
d): the unified diff, syntax highlighted; strategy badge when non-exact. Metadata guaranteed:title,diff,additions,deletions,extra.strategy,extra.replacements. - Completion line in conversation:
✓ src/context/engine.ts +31 −12(CLAUDE.md §14).
5. grep
5.1 Schema
{
"name": "grep",
"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.",
"input_schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression to search for."
},
"path": {
"type": "string",
"description": "Directory or file to search in. Defaults to the working directory."
},
"include": {
"type": "string",
"description": "Glob filter for file names, e.g. \"*.ts\" or \"src/**/*.py\"."
}
},
"required": ["pattern"]
}
}5.2 Execution semantics
Thin 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.
5.3 Success output (model-facing)
14 matches in 5 files for "ContextEngine":
src/context/engine.ts
12: export class ContextEngine {
87: // ContextEngine owns compaction triggers
src/kernel/agent.ts
41: constructor(private context: ContextEngine) {}
...Zero 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).
Truncated: 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.]
5.4 Rendering contract
- Collapsed:
▸ Search "ContextEngine" · 14 matches in 5 files - Expanded: grouped match list, highlighted match spans, click/enter opens
readview at line. Metadata guaranteed:title,matches,files,truncation?.
6. glob
6.1 Schema
{
"name": "glob",
"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.",
"input_schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match file paths against."
},
"path": {
"type": "string",
"description": "Directory to search in. Defaults to the working directory."
}
},
"required": ["pattern"]
}
}6.2 Execution semantics
Implemented 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.
6.3 Output
23 files match "src/**/*.ts" (newest first):
src/context/engine.ts
src/kernel/agent.ts
...Zero: 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.]
6.4 Rendering contract
- Collapsed:
▸ Glob src/**/*.ts · 23 files - Expanded: path list with mtime badges. Metadata:
title,files,truncation?.
7. bash
7.1 Schema
{
"name": "bash",
"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.",
"input_schema": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute."
},
"timeout_ms": {
"type": "integer",
"description": "Time budget in milliseconds before the command is moved to the background. Default 120000, maximum 300000."
},
"workdir": {
"type": "string",
"description": "Working directory for the command. Defaults to the project working directory. Use this instead of 'cd'."
}
},
"required": ["command"]
}
} 7.2 Execution semantics — and the process boundary
- Runs via
workspace.execin 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. - Environment: inherited, minus KHAELOR-internal variables;
NO_COLORunset (color stripped at render, kept in spill). - Permission evaluation happens on the parsed command before execution (capabilities:
process.execute, plus derivednetwork.access/git.modify/file.write.outsideProject— PERMISSION_MODEL §2–3). - 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, andbashreturns immediately with the output so far. Nothing is silently killed; nothing blocks forever. (OpenHands'exit_code=-1soft-timeout convention is explicitly rejected as primary mechanism — the redirect is structural, not a convention the model must learn.) - User interrupt (Esc) kills the process group of a foreground
bashcommand (it was meant to be short-lived) — unlikeprocess-managed processes, which survive. - Events:
ProcessStarted/ProcessExited(durable);ProcessOutputchunks are ephemeral, with the completed result durable inToolCompleted.
7.3 Output and truncation
$ npm test
> proj@0.3.1 test
> vitest run
✓ src/context/engine.test.ts (14 tests)
...
[exit code 0 · 3.2s · cwd /Users/x/dev/proj]Limits: 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).
Redirect result:
Command still running after 120s — moved to background as process p4.
Output so far:
...
Use process {"action":"read","id":"p4"} to see new output, or {"action":"stop","id":"p4"} to stop it.7.4 Errors
- Spawn failure:
Command failed to start: npmm: command not found. Did you mean "npm"?(PATH near-miss probe, best-effort). - Permission denial: model-facing prose from PERMISSION_MODEL §5.4 — a course correction, not a dead end.
7.5 Rendering contract
- Collapsed:
▸ Run npm test · exit 0 · 3.2s(failure:▸ Run npm test · exit 1 · 4.1swith failure styling + symbol, never color alone). - 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).
8. process
Model-facing background process manager (ADR-8 — OpenCode's biggest tool-level gap, "do not inherit the omission"). One tool, action-discriminated, 5 parameters.
8.1 Schema
{
"name": "process",
"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.",
"input_schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["start", "list", "read", "write", "stop"],
"description": "The operation to perform."
},
"command": {
"type": "string",
"description": "Shell command to launch. Required for 'start'."
},
"id": {
"type": "string",
"description": "Process id, e.g. \"p3\". Required for 'read', 'write', 'stop'."
},
"input": {
"type": "string",
"description": "Text to send to stdin. Required for 'write'. End with \\n to submit a line."
},
"offset": {
"type": "integer",
"description": "For 'read': 1-based output line to read from, instead of 'new output since last read'."
}
},
"required": ["action"]
}
}8.2 ProcessManager lifecycle
export interface ManagedProcess {
id: string; // "p1", "p2" … session-scoped handle — NOT the OS pid
pid: number; // OS pid, with start-time recorded (PID-reuse guard, Hermes §6.2)
command: string;
status: "running" | "exited" | "stopped" | "failed";
exitCode: number | null;
startedAt: number;
cwd: string;
logPath: string; // full output spill: ~/.khaelor/process-logs/<session>/<id>.log
}
export interface ProcessManager {
start(command: string, cwd: string): Promise<ManagedProcess>;
list(): ManagedProcess[];
read(id: string, opts?: { offset?: number }): ProcessRead;
write(id: string, input: string): Promise<void>;
stop(id: string): Promise<{ exitCode: number | null }>; // SIGTERM → 3s grace → SIGKILL, whole process group
adopt(child: SpawnedChild): ManagedProcess; // bash timeout redirect (§7.2)
stopAll(): Promise<void>; // session end
}- PTY-backed where available (dev servers behave correctly), own process group.
- 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, andreadwithoffsetreads from the log. - Read cursor: each process keeps a per-session read cursor;
readwithoutoffsetreturns lines since the cursor and advances it (Hermes' poll/log pagination). Read page cap: 300 lines / 20 KB with standard spill marker pointing atlogPath. - Exit is detected and recorded (
ProcessExiteddurable event) even if the model never reads again;listand 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. - User interruption (Esc) does not touch managed processes (ADR-11).
stopAllruns on session end after user-visible notice.
8.3 Outputs (model-facing)
start:
Started p3 (pid 41232): npm run dev
cwd /Users/x/dev/proj · log /Users/x/.khaelor/process-logs/s_ab12/p3.log
First output (waited up to 2s):
VITE v5.4.1 ready in 431 ms
➜ Local: http://localhost:5173/
Use process {"action":"read","id":"p3"} for new output.(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: ….)
list:
PROCESSES
p3 npm run dev running 04:32 pid 41232
p4 pytest -x running 00:18 pid 41390
p1 npm test exited code 0read: 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.
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).
stop: Stopped p3 (npm run dev) · exit code null (SIGTERM) · ran 06:12. Full log: /…/p3.log
8.4 Errors
- Unknown id:
No process "p7". Active: p3 (npm run dev, running), p4 (pytest, running). Use {"action":"list"} to see all. write/readon exited process: states the exit code and points at the log path.- Missing conditional params (e.g.
startwithoutcommand): schema-level repair prose (§1.2).
8.5 Rendering contract
- Collapsed:
▸ Process start npm run dev · p3 running/▸ Process read p3 · 47 new lines/▸ Process stop p3 · exited - Expanded: per-action — start shows first output; read shows the page; list renders the table.
- Status bar integration: running process count (
⚙ 2) appears in the status bar;/processesopens the full panel with live tails. Metadata:title,processId,exitCode?,extra.status,extra.newLines?.
9. Conformance checklist (tests to ship with src/tools/)
- Every schema round-trips: Zod → JSON Schema → Anthropic
input_schema; ≤5 params each. - Golden tests for every model-facing output and error string in this document.
- Edit cascade: one fixture per strategy (1–9), plus ambiguity, disproportionate-guard, CRLF, BOM, replace_all-zero-match.
- Read-before-write and external-modification enforcement for
writeandedit; registry rebuild on resume. - Truncation: head/tail boundaries exact; spill files created; 64 KB executor backstop.
-
bashtimeout redirect: command survives, appears inprocess list, output continuous across the seam. - Process-group kill:
stopreaps grandchildren; PID-reuse guard. - No
node:fs/node:child_processimports outsidesrc/workspace/(lint guard, ADR-13). - Header reminder fires for new headerless source files; not for
.json/vendored paths.
Author: Simon-Pierre Boucher · contact@spboucher.ai