Spec: Agent Tooling

Purpose

This spec defines the agent tooling layer: the daemon subsystem that provides tools to the primary agent and subagents for interacting with the project workspace. It owns the tool registry, tool dispatch, and the concrete implementations of built-in tools across three domains:

  1. File & search tools — read, write, create, find, grep, and AST-aware structural operations on source files. Content editing via exact-string replacement (edit.text). Adopted from oh-my-pi's Rust implementations for speed.
  2. Code intelligence (code.lsp) — diagnostics, go-to-definition, find-references, symbols, rename, and code actions via the Language Server Protocol. A single code.lsp tool with action-based dispatch. LSP is a first-class citizen alongside linters, not an afterthought.
  3. Debugger (debug) — breakpoints, stepping, stack inspection, variable evaluation, and conditional watches via the Debug Adapter Protocol. A single debug tool with action-based dispatch. Real debugging — not console.log.

This document is normative for:

  • The tool registry — how tools are declared, discovered, and versioned.
  • The tool dispatch protocol — how agents request tools and receive results within the session's run model.
  • The built-in tool catalog — every tool's name, parameters, return shape, and behavior.
  • The LSP bridge — how language servers are spawned, multiplexed, and exposed as actions of the code.lsp tool.
  • The DAP bridge — how debug adapters are spawned, session-scoped, and exposed as actions of the debug tool.
  • The file tool implementations — which oh-my-pi tools are adopted, which are adapted, and which are built fresh.
  • The sandbox integration — how tools interact with cage policies, seccomp profiles, and filesystem allowlists.

It is not normative for:

  • The session manager's run model or tool_calls table schema (that's session-manager.md).
  • The sandbox mechanism itself (that's sandbox.md).
  • The plugin host's tool-exposure mechanism (that's plugin-host.md — plugins expose their own methods; this spec covers built-in daemon tools).
  • The WebSocket framing of tool calls/results (that's http-api.md).
  • The task runner (that's task-runner.md — operator-initiated project tasks, not agent-callable tools).

Constraints (from ADRs)

Constraint Source
Daemon runtime is Bun; Rust tools integrated via FFI (bun:ffi) or compiled sidecar binaries ADR-0004
Subagent tools are daemon-mediated; subagents in cages cannot directly access host resources ADR-0009
default seccomp blocks ptrace; DAP attachment requires relaxed seccomp on the target cage ADR-0009
Plugins always run with default seccomp; no relaxed for plugins ADR-0008
All file paths are project-root-relative; tools enforce the same boundary ADR-0011
Tools must work under both deployment modes (per-user UID or system-wide kaged UID) ADR-0010

Architecture

Why built-in, not plugin-provided

Three factors push these tools into the daemon core rather than the plugin boundary:

  1. Performance. File reads, searches, and edits are the most frequently called tools in any coding session. Every call through the plugin JSON-RPC boundary pays serialization, IPC, and process-switch costs. Built-in tools eliminate this.
  2. Sandbox mediation. Caged subagents cannot touch the host filesystem directly. The daemon must broker every file operation against the cage's fs allowlist. Built-in tools enforce this at the call site — no capability-overreach possible.
  3. Seccomp constraints. DAP requires ptrace (blocked by default seccomp). Plugins are locked to default seccomp with no relaxed option (plugin-host.md). LSP servers need to execute arbitrary compilers/interpreters. Neither fits the plugin sandbox model.

Component layout

                   ┌──────────────────────────────────────┐
                   │          Session Manager             │
                   │  (dispatches tool calls from runs)   │
                   └──────────────┬───────────────────────┘
                                  │ tool_call(name, params, caller_context)
                                  ▼
                   ┌──────────────────────────────────────┐
                   │          ToolRegistry                │
                   │  built-in tools + plugin-declared    │
                   │  tools, unified dispatch             │
                   └───┬──────────┬──────────┬────────────┘
                       │          │          │
              ┌────────▼──┐  ┌───▼────┐  ┌──▼──────────┐
              │ FileTools  │  │  LSP   │  │    DAP      │
              │ (Rust FFI) │  │ Bridge │  │   Bridge    │
              └────────────┘  └───┬────┘  └──┬──────────┘
                                  │          │
                            ┌─────▼──┐  ┌───▼──────────┐
                            │LS Pool │  │ DA Pool      │
                            │(per-   │  │(per-session  │
                            │project)│  │ per-runtime) │
                            └────────┘  └──────────────┘

Five subsystems in packages/agent-tooling/:

  • ToolRegistry — the catalog of all available tools (built-in and plugin-declared). Resolves tool names, validates parameters, enforces per-caller permissions, dispatches calls.
  • FileTools — file read/write/edit, search (grep, glob), and AST-aware operations. Rust implementations called via Bun FFI.
  • LSPBridge — spawns and manages language server processes, translates agent tool calls into LSP JSON-RPC, returns structured results.
  • DAPBridge — spawns and manages debug adapter processes, translates agent tool calls into DAP JSON messages, manages debug sessions.
  • ToolPermissions — enforces cage-derived access control on every tool call. A subagent in a cage with fs: [{mode: ro, path: ./src}] cannot call file.write on ./data/.

Tool registry

Registration

Tools are registered at daemon startup. Built-in tools are always present. Plugin-declared tools are registered when a plugin completes its initialize handshake (per plugin-host.md).

interface ToolDefinition {
  name: string;                    // dot-delimited, e.g. "file.read", "code.lsp"
  namespace: "file" | "code" | "debug" | "search" | "shell" | "kaged" | string;  // grouping
  description: string;             // shown in tool-use prompts
  parameters: JSONSchema;          // input validation
  returns: JSONSchema;             // output shape documentation
  requires: ToolRequirement[];     // cage/permission prerequisites
  source: "builtin" | "plugin";
  plugin_name?: string;            // if source is "plugin"
  principal_scope?: "root-only";   // if set, schema rejects this tool on non-root agents
}

type ToolRequirement =
  | { kind: "fs"; mode: "ro" | "rw"; path: "caller" }   // needs fs access to caller-specified path
  | { kind: "seccomp"; profile: "relaxed" }              // needs relaxed seccomp
  | { kind: "net"; target: string }                      // needs network access
  | { kind: "capability"; name: string };                // named capability

Namespaces

Namespace Owner Tools
file FileTools (built-in) file.read, file.write, file.create
edit FileTools (built-in) edit.text
search FileTools (built-in) search.grep, search.glob, search.ast
code LSPBridge (built-in) code.lsp (actions: diagnostics, definition, references, symbols, rename, rename_file, code_actions, hover, type_definition, implementation, status, reload, capabilities, request)
debug DAPBridge (built-in) debug (actions: launch, attach, set_breakpoint, remove_breakpoint, list_breakpoints, set_instruction_breakpoint, remove_instruction_breakpoint, data_breakpoint_info, set_data_breakpoint, remove_data_breakpoint, step_into, step_over, step_out, continue, pause, stack_trace, threads, scopes, variables, evaluate, disassemble, read_memory, write_memory, modules, loaded_sources, custom_request, disconnect)
shell PtyBroker (built-in) shell.bash — execute shell commands via PTY broker
kaged (checkpoint) Built-in (daemon) kaged.checkpoint — model-initiated checkpoint (no principal_scope restriction)
kaged (issue) Built-in (daemon) kaged.issue — unified action-dispatched issue management (list, get, create, comment, transition) — principal_scope: "root-only"
kaged (todo) Built-in (daemon) kaged.todo — unified action-dispatched issue-bound todo management (view, set, add, start, done, drop, note) — principal_scope: "root-only"
kaged (interaction) Built-in (daemon) kaged.ask, kaged.form — structured operator interaction, checkpoint-like pause/resume — principal_scope: "root-only"
kaged.workflow Built-in (daemon) kaged.workflow.trigger, kaged.workflow.list, kaged.workflow.statusprincipal_scope: "root-only" (spec only, not implemented)
<plugin>.* Plugin Plugin-declared methods, proxied through the plugin host

Built-in namespace names (file, edit, search, code, debug, shell, compute, project, kaged) cannot be used by plugins. Per ADR-0033, additional namespaces (web, discover, media, job) are also reserved. Plugin methods follow the existing naming rules from plugin-host.md.

Tools carrying principal_scope: "root-only" are rejected by the DSL schema if an operator attempts to enable them on any non-root agent. See project-dsl.md § Tool resolution.

Dispatch

When the primary or a subagent emits a tool call:

  1. Resolve. The registry looks up the tool by name. Unknown tool → error -32601 (tool_not_found).
  2. Validate params. Input validated against the tool's parameters schema. Invalid → error -32602 (invalid_params).
  3. Authorize. The ToolPermissions module checks the caller's cage policy against the tool's requires. Denied → error -32001 (capability_denied).
  4. Execute. Dispatch to the owning subsystem (FileTools, LSPBridge, DAPBridge, or plugin host).
  5. Return. Result (or error) returned to the session manager, which records it in tool_calls and streams it to the WebSocket.

Caller context

Every tool call carries the caller's identity and sandbox context:

interface ToolCallContext {
  session_id: string;
  run_id: string;
  caller: string;                  // tree-position path: "primary", "primary.subagents.scraper", etc.
  project_id: string;
  project_root: string;            // absolute host path
  cage_policy: CagePolicy | null;  // null for uncaged agents (cage: disabled)
  request_id: string;              // for audit correlation
}

The caller field encodes the agent's position in the AgentSpec tree (e.g. "primary", "primary.subagents.scraper", "primary.subagents.builder.subagents.linter"). This path is used for audit logging, tool resolution (root-only tools check caller === "primary"), and permission scoping.

The cage_policy is the effective policy the caller is running under. Tools use it to enforce access boundaries without consulting the sandbox directly — the policy is the single source of truth. Agents with cage: disabled have cage_policy: null.

Per-agent tool resolution

Per ADR-0022, tools are resolved per agent, not per project. There is no project-level tools: block. Each agent in the AgentSpec tree carries its own tools: override map (or omits it to accept defaults).

Resolution chain (evaluated once at session start, per agent):

  1. Built-in registry. All built-in tools (file.*, edit.*, search.*, code.lsp, debug, kaged.issue.*, kaged.workflow.*) and plugin-declared tools exist in the global registry. Existence in the registry does not mean availability — it means the tool can be enabled.

  2. Role-based defaults. The root agent (the AgentSpec at ProjectDsl.primary) receives kaged.issue.* and kaged.workflow.* enabled by default. Every other agent in the tree starts with no tools enabled. The operator opts in per agent via the agent's tools: block.

  3. Agent's tools: block. A Record<string, ToolOverride> on the AgentSpec. Each key is a tool name or glob pattern; the value is { enabled: true } or { enabled: false } (to suppress a role-based default). Glob patterns are expanded against the registry at resolution time.

  4. principal_scope enforcement. Tools with principal_scope: "root-only" (currently kaged.issue.* and kaged.workflow.*) are rejected by the DSL parser if an operator attempts to enable them on any non-root agent. This is a schema-level rejection, not a runtime check — the project fails to load.

  5. Cage filter at dispatch. Even after an agent's tool set is resolved, every tool call passes through ToolPermissions at dispatch time. A tool that is enabled but whose requires entries are not satisfied by the agent's cage is rejected with capability_denied. This is the runtime complement to the schema-level resolution.

Example resolution:

# Root agent — gets kaged.issue.* and kaged.workflow.* by default
primary:
  model: smart-generalist
  system_prompt: project:/prompts/primary.md
  cage: disabled
  # tools: omitted → role-based defaults only (kaged.issue.*, kaged.workflow.*)
  subagents:
    coder:
      model: smart-fast
      system_prompt: project:/prompts/coder.md
      cage:
        fs: [{ mode: rw, path: ./src }]
        net: { allow: [] }
        state: ephemeral
      tools:
        "file.*": { enabled: true }
        "search.*": { enabled: true }
        "lsp.*": { enabled: true }
        # kaged.issue.create: { enabled: true }  ← PARSE ERROR: root-only

In this example, the root agent can create/update issues and trigger workflows. The coder subagent can read/write files, search, and use LSP — but cannot touch issues or workflows. The coder's file.write calls are further constrained by its cage: writes outside ./src are rejected at dispatch time.

Synthetic agent-{key} tools. When the harness compiles the AgentSpec tree, each agent's direct children are registered as synthetic tools named agent-{key} on the parent (see agent.md). These synthetic tools do not appear in the tools: override map — they are always available to the parent. The operator controls the call graph by controlling the tree structure, not by toggling synthetic tools.


File & search tools

Provenance

These tools are adopted from oh-my-pi's Rust implementations. oh-my-pi's file tools are battle-tested across millions of coding sessions. We adopt the Rust core and wrap it with kaged's permission layer.

oh-my-pi tool kaged tool Adoption strategy
read file.read Adopt. Rust binary, called via Bun FFI. Line-numbered output, offset/limit pagination.
write file.write Adopt. Full-file write with content.
edit edit.text Adopt. Hashline-anchored patching with stale-anchor recovery.
ast_edit search.ast / edit.ast Adapt. Adopt the ast-grep search (search.ast); structural rewrite exposed as edit.ast (dry-run by default, requires dry_run: false to apply).
search (grep) search.grep Adopt. Regex content search with file-pattern filtering.
find (glob) search.glob Adopt. Fast glob matching with modification-time sorting.
bash (not adopted) Covered by the PTY broker and task runner. Agents use terminal access or operator-initiated tasks.
eval (not adopted) Security concern; arbitrary code eval inside the daemon is against kaged's threat model.
lsp code.lsp Build fresh. oh-my-pi's LSP tool is a monolith; kaged exposes a single code.lsp tool with action-based dispatch and a managed server pool.
debug debug Build fresh. oh-my-pi's debug tool wraps DAP; kaged builds a session-scoped DAP bridge exposed as a single debug tool with action-based dispatch.

Integration strategy

The Rust implementations are compiled as shared libraries (.so / .dylib) and loaded via bun:ffi. Each tool function is a synchronous or async FFI call that takes a path and parameters, returns structured JSON.

// Conceptual — actual FFI bindings in packages/agent-tooling/ffi/
import { dlopen, FFIType } from "bun:ffi";

const lib = dlopen("libkaged_filetools.so", {
  file_read: {
    args: [FFIType.ptr, FFIType.i32, FFIType.i32],  // path, offset, limit
    returns: FFIType.ptr,                             // JSON result
  },
  search_grep: {
    args: [FFIType.ptr, FFIType.ptr, FFIType.ptr],   // pattern, path, include
    returns: FFIType.ptr,
  },
  // ...
});

If FFI proves fragile across platforms, the fallback is a compiled Rust sidecar binary (kaged-filetools) that the daemon communicates with over a Unix socket. The tool API shape is identical either way.

Tool: file.read

Read a file or directory listing.

Parameters:

{
  "path": "./src/main.ts",
  "offset": 1,
  "limit": 200
}
Param Type Required Description
path string yes Project-relative path to read.
offset integer no Line number to start from (1-indexed). Default: 1.
limit integer no Max lines to return. Default: 2000.

Returns:

{
  "path": "./src/main.ts",
  "type": "file",
  "content": "1: import { serve } from 'bun';\n2: ...",
  "total_lines": 42,
  "truncated": false
}

For directories, returns an entry listing (one per line, trailing / for subdirectories).

Permission: requires read:fs on the resolved path. Always allowed for the primary (no cage). For caged subagents, the path must fall inside an fs entry with mode ro or rw.

Behavior:

  • Lines are prefixed with their line number (N: content).
  • Lines longer than 2000 characters are truncated with a [truncated] marker.
  • Binary files return {"type": "binary", "size": N, "mime": "..."} instead of content.
  • If path does not exist, returns error file_not_found.
  • Symlinks are resolved; the resolved path must still be within the cage's allowlist.

Tool: file.write

Write (create or overwrite) a file.

Parameters:

{
  "path": "./src/new-module.ts",
  "content": "export function greet() { ... }"
}
Param Type Required Description
path string yes Project-relative path.
content string yes Full file content.

Returns:

{
  "path": "./src/new-module.ts",
  "bytes_written": 1234,
  "created": true
}

Permission: requires write:fs on the resolved path (cage fs entry with mode: rw). The primary can write anywhere in the project root.

Behavior:

  • Creates parent directories if they don't exist.
  • Overwrites existing files entirely — no merge.
  • The agent MUST have read the file first (tracked by the tool registry) before overwriting. If the agent hasn't read the file, the tool returns error file_not_read with message "Read the file before overwriting it." This prevents blind overwrites.

Tool: edit.text

Apply a targeted edit to a file using exact string matching.

Parameters:

{
  "path": "./src/main.ts",
  "old_string": "const port = 3000;",
  "new_string": "const port = parseInt(process.env.PORT ?? '3000');",
  "replace_all": false
}
Param Type Required Description
path string yes Project-relative path.
old_string string yes Exact text to find.
new_string string yes Replacement text. Must differ from old_string.
replace_all boolean no Replace all occurrences. Default: false.

Returns:

{
  "path": "./src/main.ts",
  "replacements": 1
}

Permission: requires write:fs on the resolved path.

Behavior:

  • old_string not found → error old_string_not_found.
  • old_string found multiple times and replace_all is false → error multiple_matches with count.
  • old_string == new_string → error no_change.
  • Preserves file encoding (UTF-8) and line endings.
  • The agent MUST have read the file first.

Tool: file.create

Create a new file. Fails if the file already exists.

Parameters:

{
  "path": "./src/utils/helpers.ts",
  "content": "export function clamp(n: number, min: number, max: number) { ... }"
}
Param Type Required Description
path string yes Project-relative path.
content string yes File content.

Returns:

{
  "path": "./src/utils/helpers.ts",
  "bytes_written": 256,
  "created": true
}

Permission: requires write:fs on the resolved path.

Behavior:

  • If file exists → error file_exists. Use file.write to overwrite.
  • Creates parent directories if needed.

Tool: search.grep

Search file contents using regular expressions.

Parameters:

{
  "pattern": "TODO|FIXME",
  "path": "./src",
  "include": "*.ts",
  "output_mode": "content",
  "head_limit": 50
}
Param Type Required Description
pattern string yes Regex pattern.
path string no Directory to search. Default: project root.
include string no File glob filter (e.g., *.ts, *.{ts,tsx}).
output_mode enum no content (matching lines), files_with_matches (paths only), count. Default: files_with_matches.
head_limit integer no Max results. Default: unlimited.

Returns:

{
  "matches": [
    { "file": "./src/main.ts", "line": 42, "content": "// TODO: handle errors" }
  ],
  "total_matches": 1,
  "truncated": false
}

Permission: requires read:fs on the search path. For caged subagents, results are filtered to only include files within the cage's readable mounts.

Behavior:

  • Timeout: 60 seconds.
  • Output cap: 256 KB. Truncated results include "truncated": true.
  • Binary files are skipped.
  • .gitignore patterns are respected by default.

Tool: search.glob

Find files by name pattern.

Parameters:

{
  "pattern": "**/*.test.ts",
  "path": "./src"
}
Param Type Required Description
pattern string yes Glob pattern.
path string no Directory to search. Default: project root.

Returns:

{
  "files": ["./src/auth.test.ts", "./src/db.test.ts"],
  "count": 2,
  "truncated": false
}

Permission: requires read:fs on the search path.

Behavior:

  • Timeout: 60 seconds.
  • File limit: 100. Truncated results include "truncated": true.
  • Results sorted by modification time (most recent first).
  • .gitignore patterns respected.

Tool: search.ast

Search code using AST-aware pattern matching via ast-grep.

Parameters:

{
  "pattern": "console.log($MSG)",
  "lang": "typescript",
  "paths": ["./src"],
  "context": 2
}
Param Type Required Description
pattern string yes AST pattern with meta-variables ($VAR, $$$). Must be a complete AST node.
lang enum yes Target language. Supported: typescript, javascript, tsx, python, rust, go, java, c, cpp, css, html, json, yaml, and others as ast-grep supports.
paths list of strings no Directories to search. Default: project root.
globs list of strings no Include/exclude globs. Prefix ! to exclude.
context integer no Context lines around match. Default: 0.

Returns:

{
  "matches": [
    {
      "file": "./src/main.ts",
      "line": 15,
      "content": "console.log(\"server started\")",
      "meta_variables": { "MSG": "\"server started\"" },
      "context_before": ["  const server = serve({"],
      "context_after": ["  });"]
    }
  ],
  "count": 1
}

Permission: requires read:fs on the search paths.

Behavior:

  • Patterns must be complete AST nodes (valid code fragments). Invalid patterns → error invalid_pattern.
  • Meta-variables: $VAR matches a single node, $$$ matches multiple nodes.
  • Uses the ast-grep Rust library via FFI.

Tool: edit.ast

Apply AST-aware structural rewrite rules to source files using ast-grep. Dry-run by default — agents must set dry_run: false to persist changes.

Parameters:

{
  "rewrites": { "console.log($MSG)": "logger.debug($MSG)" },
  "lang": "typescript",
  "path": "./src",
  "glob": "*.ts",
  "dry_run": true
}
Param Type Required Description
rewrites object yes Map of pattern string → replacement template. Each key is an ast-grep pattern with meta-variables; each value is the replacement template using the same variables.
lang enum no Target language. Supported: typescript, javascript, tsx, python, rust, go, java, c, cpp, css, html, json, yaml, and others as ast-grep supports. Inferred from file extensions when omitted if all candidates share one language.
path string no Single file or directory to rewrite. Default: project root.
glob string no Optional glob filter within the search root.
dry_run boolean no When true (default), computes changes without writing files. Returns a preview of all replacements.
max_replacements integer no Cap on replacement applications across all files. Default: unlimited.
max_files integer no Cap on distinct files that may be modified. Default: unlimited.

Returns:

{
  "changes": [
    {
      "path": "./src/main.ts",
      "before": "console.log(\"server started\")",
      "after": "logger.debug(\"server started\")",
      "byte_start": 120,
      "byte_end": 150,
      "start_line": 15,
      "start_column": 8,
      "end_line": 15,
      "end_column": 45
    }
  ],
  "file_changes": [
    { "path": "./src/main.ts", "count": 1 }
  ],
  "total_replacements": 1,
  "files_touched": 1,
  "files_searched": 3,
  "applied": false,
  "limit_reached": false
}

Permission: requires write:fs on the resolved path when dry_run is false. Requires read:fs when dry_run is true.

Behavior:

  • Dry-run by default ("applied": false). Agents must explicitly set dry_run: false to write changes to disk.
  • When dry_run: true, the tool returns the full set of proposed replacements without modifying any files.
  • When dry_run: false, the tool applies all non-overlapping edits and writes files back.
  • Parse or pattern errors on individual files are collected and returned in parse_errors without failing the entire operation (unless the file is the only candidate).
  • Invalid patterns (unparseable ast-grep syntax) → error invalid_pattern.
  • Language inference failure (mixed extensions, unsupported extension) → error invalid_params with detail.
  • Uses the ast-grep Rust library via FFI, sharing the same parser infrastructure as search.ast.

Code intelligence tool (code.lsp)

Design philosophy

LSP is a first-class citizen in kaged's agent tooling. Coding agents that can query diagnostics, navigate to definitions, and perform safe renames produce dramatically better code than agents that work blind.

The LSP bridge is not a passthrough — it translates LSP's chatty, stateful JSON-RPC protocol into discrete, stateless agent actions. An agent calls code.lsp with action: "diagnostics" and gets a clean result. The bridge manages the underlying language server lifecycle, initialization, file synchronization, and capability negotiation.

LSP bridge architecture

                ┌──────────────────────┐
                │    Agent (primary    │
                │    or subagent)      │
                └──────────┬───────────┘
                           │ code.lsp({ action: "diagnostics", path: "./src/main.ts" })
                           ▼
                ┌──────────────────────┐
                │     ToolRegistry     │
                └──────────┬───────────┘
                           │
                           ▼
                ┌──────────────────────┐
                │     LSPBridge        │
                │  ┌────────────────┐  │
                │  │  ServerPool    │  │
                │  │                │  │
                │  │ ts-server ─────│──│──▶ typescript-language-server
                │  │ pyright  ──────│──│──▶ pyright-langserver
                │  │ rust-analyzer ─│──│──▶ rust-analyzer
                │  │ ...            │  │
                │  └────────────────┘  │
                │                      │
                │  ┌────────────────┐  │
                │  │  FileSync      │  │──── tracks open/changed files
                │  └────────────────┘  │     for textDocument/didOpen etc.
                └──────────────────────┘

Language server pool

The LSPBridge maintains a per-project pool of language servers. Servers are spawned on demand (first tool call for a given language in a project) and kept alive for the project's lifetime.

Server lifecycle:

  1. Spawn. On first code.lsp call for a language, the bridge:
    • Resolves the language server binary from a configurable registry (see LSP server configuration).
    • Spawns the server as a child process of the daemon, outside any subagent cage (the daemon is the LSP client).
    • Performs the LSP initialize / initialized handshake.
    • Sends workspace/didOpen for files the agents have read so far.
  2. Active. The server processes requests. File changes made by agents via file.write / edit.text trigger textDocument/didChange notifications.
  3. Idle timeout. If no code.lsp call is made for 10 minutes, the server is sent shutdown + exit. It is re-spawned on next use.
  4. Teardown. On project unload or daemon shutdown, all servers for the project are shut down.

Why daemon-side, not cage-side:

  • Language servers often need the full project tree (imports, node_modules, tsconfig.json). Caged subagents may only see a slice.
  • Language servers are expensive to start. A single daemon-side instance serves all agents in a project.
  • The daemon can enforce that agents only see diagnostics for files within their cage — the bridge filters results post-hoc.

LSP server configuration

The LSP bridge needs to know which language server to use for each language. Configuration lives in the project DSL (new optional field) and/or the operator's local config.

Project DSL extension (optional):

# in .kaged/project.yaml
lsp:
  servers:
    - lang: typescript
      command: ["typescript-language-server", "--stdio"]
    - lang: python
      command: ["pyright-langserver", "--stdio"]
    - lang: rust
      command: ["rust-analyzer"]

Local config fallback:

# in config.toml
[lsp.servers.typescript]
command = ["typescript-language-server", "--stdio"]

[lsp.servers.python]
command = ["pyright-langserver", "--stdio"]

Resolution order: project DSL > local config > kaged built-in defaults.

Built-in defaults (v0):

Language Default server Notes
TypeScript/JavaScript typescript-language-server --stdio Wraps tsserver.
Python pyright-langserver --stdio Fast, zero-config.
Rust rust-analyzer Standard.
Go gopls Standard.

Operators install language servers on their host. kaged does not bundle them. If a server binary is not found, code.lsp actions for that language return error lsp_server_not_found with the expected command and install instructions.

File synchronization

The LSP protocol requires the client to notify the server about file opens and changes. The LSPBridge maintains a FileSync tracker:

  • file.read triggers textDocument/didOpen if the file hasn't been opened in the server yet.
  • file.write / edit.text triggers textDocument/didChange with the new content.
  • File sync is project-scoped, not agent-scoped. If subagent A edits a file, subagent B's next code.lsp diagnostics call sees the change.

This means LSP results are always current with the workspace state, even across concurrent subagent edits within the same session.

Tool: code.lsp

A single unified tool with action-based dispatch. The action parameter selects the LSP operation; action-specific parameters are flat siblings validated by the handler. All actions require read:fs on the path unless noted otherwise.

Common parameters (all actions):

Param Type Required Description
action enum yes One of: diagnostics, definition, references, symbols, rename, code_actions, hover.
path string yes File or directory path. Required for all actions.

Position parameters (required for definition, references, rename, code_actions, hover):

Param Type Required Description
line integer conditional Line number (1-indexed). Required for position-based actions.
character integer conditional Column (0-indexed). Required for position-based actions.

Permission: requires { kind: "fs", mode: "rw", path: "caller" }. Results are filtered to files within the caller's cage allowlist where applicable.

Action: diagnostics

Get errors, warnings, and hints for a file or directory.

Additional parameters:

Param Type Required Description
severity enum no Filter: error, warning, information, hint, all. Default: all.

Example:

{
  "action": "diagnostics",
  "path": "./src/main.ts",
  "severity": "error"
}

Returns:

{
  "path": "./src/main.ts",
  "diagnostics": [
    {
      "line": 15,
      "character": 8,
      "end_line": 15,
      "end_character": 12,
      "severity": "error",
      "message": "Property 'naem' does not exist on type 'User'. Did you mean 'name'?",
      "code": "ts(2551)",
      "source": "typescript"
    }
  ],
  "count": 1
}

Behavior:

  • Language is auto-detected from file extension.
  • If no language server is running for the detected language, one is spawned (may take a few seconds on first call).
  • For directories, the tool scans files matching the language's default extensions and returns aggregated diagnostics.
  • Timeout: 30 seconds (language servers can be slow on first analysis).

Action: definition

Jump to the definition of a symbol.

Example:

{
  "action": "definition",
  "path": "./src/main.ts",
  "line": 15,
  "character": 8
}

Returns:

{
  "definitions": [
    {
      "path": "./src/types.ts",
      "line": 3,
      "character": 0,
      "preview": "export interface User {"
    }
  ]
}

Permission: requires read:fs on both the source file and the definition target. Definitions in files outside the caller's cage are returned with path only (no preview).

Action: references

Find all usages of a symbol across the workspace.

Additional parameters:

Param Type Required Description
include_declaration boolean no Include the declaration itself. Default: false.

Example:

{
  "action": "references",
  "path": "./src/types.ts",
  "line": 3,
  "character": 17,
  "include_declaration": false
}

Returns:

{
  "references": [
    { "path": "./src/main.ts", "line": 15, "character": 8, "preview": "const user: User = ..." },
    { "path": "./src/auth.ts", "line": 42, "character": 12, "preview": "function validateUser(u: User)" }
  ],
  "count": 2
}

Permission: read:fs on source. References outside the caller's cage are included (read-only information about the broader codebase is safe).

Action: symbols

Get symbols from a file (document outline) or search across the workspace.

Additional parameters:

Param Type Required Description
scope enum no document (file outline) or workspace (project-wide search). Default: document.
query string conditional Required for workspace scope. Symbol name to search.
limit integer no Max results. Default: 50.

Example:

{
  "action": "symbols",
  "path": "./src/main.ts",
  "scope": "document",
  "limit": 50
}

Returns:

{
  "symbols": [
    { "name": "serve", "kind": "function", "path": "./src/main.ts", "line": 5, "character": 0 },
    { "name": "handleRequest", "kind": "function", "path": "./src/main.ts", "line": 12, "character": 0 }
  ],
  "count": 2
}

Action: rename

Rename a symbol across the workspace.

Additional parameters:

Param Type Required Description
new_name string yes New symbol name.

Example:

{
  "action": "rename",
  "path": "./src/types.ts",
  "line": 3,
  "character": 17,
  "new_name": "AppUser"
}

Returns:

{
  "changes": [
    { "path": "./src/types.ts", "edits": 1 },
    { "path": "./src/main.ts", "edits": 3 },
    { "path": "./src/auth.ts", "edits": 2 }
  ],
  "total_edits": 6,
  "files_changed": 3
}

Permission: requires write:fs on all files that would be changed. If any file falls outside the caller's cage, the rename is rejected with error rename_scope_exceeds_cage listing the inaccessible files. The agent must request a broader cage or delegate to the primary.

Behavior:

  • The bridge first calls textDocument/prepareRename to validate the rename is possible.
  • If preparation fails (not a renameable symbol), returns error rename_not_possible.
  • On success, all edits are applied atomically. If any file write fails, the entire rename is rolled back.

Action: code_actions

Get available code actions (quick fixes, refactors) for a location.

Additional parameters:

Param Type Required Description
include_diagnostics boolean no Include diagnostic-associated actions. Default: true.

Example:

{
  "action": "code_actions",
  "path": "./src/main.ts",
  "line": 15,
  "character": 8,
  "include_diagnostics": true
}

Returns:

{
  "actions": [
    {
      "title": "Change spelling to 'name'",
      "kind": "quickfix",
      "preferred": true,
      "edits": [
        { "path": "./src/main.ts", "line": 15, "old": "naem", "new": "name" }
      ]
    }
  ],
  "count": 1
}

Applying actions: the agent selects an action and calls edit.text with the provided edits. Code actions are informational — the agent decides whether to apply them.

Action: hover

Get hover information (type info, documentation) for a symbol.

Example:

{
  "action": "hover",
  "path": "./src/main.ts",
  "line": 15,
  "character": 8
}

Returns:

{
  "content": "(property) User.name: string",
  "documentation": "The user's display name.",
  "range": { "start": { "line": 15, "character": 8 }, "end": { "line": 15, "character": 12 } }
}

Debugger tool (debug)

Design philosophy

Real debugging means breakpoints, stepping, stack inspection, and variable evaluation. Not console.log. kaged agents should debug like a senior engineer: set a breakpoint, run the program, inspect the state, hypothesize, adjust.

The DAP bridge translates the Debug Adapter Protocol's session-oriented model into a single debug tool with action-based dispatch. An agent calls debug with action: "launch" to start a debug session, action: "set_breakpoint" to set breakpoints, action: "continue" to run to the next breakpoint, and action: "variables" to inspect state.

DAP bridge architecture

                ┌──────────────────────┐
                │    Agent             │
                └──────────┬───────────┘
                           │ debug({ action: "launch", runtime: "bun", script: "./src/main.ts" })
                           ▼
                ┌──────────────────────┐
                │     ToolRegistry     │
                └──────────┬───────────┘
                           │
                           ▼
                ┌──────────────────────┐
                │     DAPBridge        │
                │  ┌────────────────┐  │
                │  │  SessionPool   │  │
                │  │                │  │
                │  │ ses_abc → DA ──│──│──▶ debug adapter process
                │  │ ses_def → DA ──│──│──▶ debug adapter process
                │  └────────────────┘  │
                └──────────────────────┘

Debug sessions

A debug session is scoped to a kaged session (one run at a time). An agent can have at most one active debug session. Debug sessions are explicitly created (debug with action: "launch" / action: "attach") and explicitly ended (action: "disconnect").

Session lifecycle:

  1. Launch/Attach. The agent calls debug with action: "launch" (start a new program under the debugger) or action: "attach" (attach to a running process). The bridge spawns a debug adapter and initializes it per DAP spec.
  2. Active. The agent sets breakpoints, runs the program, inspects state.
  3. Disconnect. The agent calls debug with action: "disconnect". The bridge terminates the debug adapter and the debugged process.
  4. Auto-cleanup. If the kaged run ends (completes, fails, or is cancelled) while a debug session is active, the bridge disconnects automatically.

Debug adapter configuration

Similar to LSP servers, debug adapters are configured per runtime:

Project DSL extension (optional):

# in .kaged/project.yaml
dap:
  adapters:
    - runtime: bun
      command: ["bun", "--inspect-brk=0", "--inspect-wait"]
      type: "bun"
    - runtime: node
      command: ["node", "--inspect-brk"]
      adapter: ["js-debug-adapter"]
      type: "pwa-node"
    - runtime: python
      adapter: ["debugpy", "--listen", "0"]
      type: "debugpy"

Built-in defaults (v0):

Runtime Debug adapter Launch mechanism
Bun (TypeScript/JS) Built-in (--inspect-brk) Bun's native WebSocket debugger with CDP-to-DAP translation
Node.js js-debug-adapter (VS Code's DAP adapter) Standard DAP over stdio
Python debugpy Standard DAP

Seccomp interaction

DAP debugging often requires ptrace for process attachment and stepping. This interacts with the sandbox:

Scenario Seccomp Behavior
Primary agent debugging No cage Full DAP access. Debug adapter runs as daemon UID.
Subagent in relaxed cage ptrace allowed DAP works. Debug adapter spawned inside the cage.
Subagent in default cage ptrace blocked debug launch/attach actions return error dap_requires_relaxed_seccomp. Agent must escalate to primary or operator.
Subagent with cage: disabled No seccomp Full DAP access, same as primary.

For Bun's --inspect debugger (WebSocket-based, not ptrace-based), the default seccomp profile is sufficient. The bridge detects the runtime and advises accordingly.

Tool: debug

A single unified tool with action-based dispatch. The action parameter selects the debug operation; action-specific parameters are flat siblings validated by the handler. At most one active debug session per agent.

Common parameter (all actions):

Param Type Required Description
action enum yes One of: launch, attach, set_breakpoint, remove_breakpoint, list_breakpoints, step_into, step_over, step_out, continue, stack_trace, variables, evaluate, disconnect.

Permission: requires { kind: "fs", mode: "ro", path: "caller" } and { kind: "capability", name: "exec" }. For caged subagents with default seccomp using non-WebSocket debuggers, returns error with guidance to use relaxed seccomp.

Action: launch

Start a program under the debugger.

Additional parameters:

Param Type Required Description
runtime string yes Runtime identifier (bun, node, python, go, etc.).
script string yes Project-relative path to the entry point.
args list of strings no Arguments to the program.
env object no Environment variables (merged with cage env).
stop_on_entry boolean no Pause on first line. Default: true.
cwd string no Working directory (project-relative). Default: project root.

Example:

{
  "action": "launch",
  "runtime": "bun",
  "script": "./src/main.ts",
  "args": ["--port", "3000"],
  "env": { "NODE_ENV": "test" },
  "stop_on_entry": true
}

Returns:

{
  "debug_session_id": "dap_01HXAB",
  "status": "stopped",
  "stopped_reason": "entry",
  "threads": [
    { "id": 1, "name": "main" }
  ]
}

Action: attach

Attach the debugger to a running process.

Additional parameters:

Param Type Required Description
runtime string yes Runtime identifier.
port integer yes Debug port.
host string no Host to connect to. Default: localhost.

Example:

{
  "action": "attach",
  "runtime": "bun",
  "port": 9229,
  "host": "localhost"
}

Returns: same shape as launch.

Action: set_breakpoint

Add a breakpoint to a source location.

Additional parameters:

Param Type Required Description
path string yes File path.
line integer yes Line number.
condition string no Conditional expression (only break when true).
hit_count integer no Break after N hits.
log_message string no Log message instead of breaking (logpoint).

Example:

{
  "action": "set_breakpoint",
  "path": "./src/main.ts",
  "line": 42,
  "condition": "user.role === 'admin'"
}

Returns:

{
  "breakpoint_id": 1,
  "verified": true,
  "actual_line": 42,
  "path": "./src/main.ts"
}

Action: remove_breakpoint

Remove a breakpoint from a source location.

Additional parameters:

Param Type Required Description
path string yes File path.
line integer yes Line number.

Example:

{
  "action": "remove_breakpoint",
  "path": "./src/main.ts",
  "line": 42
}

Action: list_breakpoints

List all active breakpoints. No additional parameters.

Example:

{
  "action": "list_breakpoints"
}

Returns:

{
  "breakpoints": [
    { "id": 1, "path": "./src/main.ts", "line": 42, "condition": "user.role === 'admin'", "verified": true },
    { "id": 2, "path": "./src/auth.ts", "line": 15, "condition": null, "verified": true }
  ]
}

Actions: step_into, step_over, step_out

Step through code.

Additional parameters:

Param Type Required Description
thread_id integer no Thread to step. Default: current/main thread.

Example:

{
  "action": "step_over",
  "thread_id": 1
}

Returns:

{
  "status": "stopped",
  "stopped_reason": "step",
  "location": {
    "path": "./src/main.ts",
    "line": 43,
    "column": 4,
    "source_preview": "  const result = await processRequest(req);"
  }
}

Action: continue

Continue execution until the next breakpoint or program exit.

Additional parameters:

Param Type Required Description
thread_id integer no Thread to continue. Default: current/main thread.

Example:

{
  "action": "continue",
  "thread_id": 1
}

Returns:

{
  "status": "stopped",
  "stopped_reason": "breakpoint",
  "breakpoint_id": 2,
  "location": {
    "path": "./src/auth.ts",
    "line": 15,
    "column": 0,
    "source_preview": "function validateToken(token: string) {"
  }
}

If the program exits without hitting a breakpoint:

{
  "status": "exited",
  "exit_code": 0
}

Action: stack_trace

Get the call stack at the current pause point.

Additional parameters:

Param Type Required Description
thread_id integer no Thread ID. Default: current thread.
levels integer no Max stack frames to return. Default: 20.

Example:

{
  "action": "stack_trace",
  "thread_id": 1,
  "levels": 10
}

Returns:

{
  "frames": [
    { "id": 0, "name": "validateToken", "path": "./src/auth.ts", "line": 15, "column": 0 },
    { "id": 1, "name": "handleRequest", "path": "./src/main.ts", "line": 28, "column": 8 },
    { "id": 2, "name": "serve", "path": "./src/main.ts", "line": 5, "column": 0 }
  ],
  "total_frames": 3
}

Action: variables

Inspect variables in the current scope.

Additional parameters:

Param Type Required Description
frame_id integer no Stack frame to inspect. Default: top frame (0).
scope enum no local, closure, global. Default: local.
filter string no Variable name filter (substring match).
expand string no Variable name to expand (show nested properties).

Example:

{
  "action": "variables",
  "frame_id": 0,
  "scope": "local"
}

Returns:

{
  "variables": [
    { "name": "token", "value": "\"eyJhbG...\"", "type": "string" },
    { "name": "user", "value": "User { name: \"alice\", role: \"admin\" }", "type": "User", "expandable": true },
    { "name": "isValid", "value": "true", "type": "boolean" }
  ]
}

With "expand": "user":

{
  "variables": [
    { "name": "user.name", "value": "\"alice\"", "type": "string" },
    { "name": "user.role", "value": "\"admin\"", "type": "string" },
    { "name": "user.id", "value": "42", "type": "number" }
  ]
}

Action: evaluate

Evaluate an expression in the current debug context.

Additional parameters:

Param Type Required Description
expression string yes Expression to evaluate.
frame_id integer no Stack frame context. Default: top frame.
context enum no watch (side-effect-free), repl (may have side effects). Default: watch.

Example:

{
  "action": "evaluate",
  "expression": "user.permissions.includes('write')",
  "frame_id": 0,
  "context": "watch"
}

Returns:

{
  "result": "false",
  "type": "boolean"
}

Security note: context: "repl" can execute arbitrary code in the debugged process. The tool warns agents to prefer "watch" for inspection. The cage's capabilities bound the blast radius — the debugged process is inside the cage, not the daemon.

Action: disconnect

End the debug session.

Additional parameters:

Param Type Required Description
terminate_debuggee boolean no Kill the debugged program. Default: true.

Example:

{
  "action": "disconnect",
  "terminate_debuggee": true
}

Returns:

{
  "disconnected": true,
  "debuggee_terminated": true
}

Project management tools (kaged.*)

Design philosophy

The kaged.issue.* and kaged.workflow.* namespaces give the root agent first-class access to the daemon's project management surface. A root agent coordinating subagents can file issues, update their status, trigger workflows, and check run results — all within the same reasoning loop that drives delegation.

These tools are root-only by design (ADR-0022 rule 5, rule 10). Subagents do not file issues or trigger workflows. Subagent work products bubble up to the root agent, which decides whether to update an issue or trigger a workflow based on the results. This keeps the audit trail single-rooted and prevents subagents from side-effecting project state.

All kaged.* tools carry principal_scope: "root-only". The DSL parser rejects them on non-root agents at load time. As a defense-in-depth measure, the registry also checks caller === "primary" at dispatch time and rejects with principal_scope_violation if a non-root caller somehow reaches the tool.

These tools do not require cage permissions (fs, net, seccomp) — they are daemon-internal API calls that read from and write to the daemon's SQLite database. The requires array is empty.

Tool: kaged.issue.list

List issues in the current project.

Parameters:

{
  "status": "open",
  "limit": 25,
  "offset": 0
}
Param Type Required Description
status enum no Filter by status: open, triaged, assigned, in_progress, resolved, rejected, reopened. Omit for all non-terminal statuses.
limit integer no Max results. Default: 25. Max: 100.
offset integer no Pagination offset. Default: 0.
search string no FTS5 full-text query against title and body.

Returns:

{
  "issues": [
    {
      "id": "01HXYZ...",
      "number": 42,
      "title": "Add dark mode support",
      "status": "triaged",
      "assignment": null,
      "created_by": "guest:01HXAB...",
      "created_at": 1716700000,
      "updated_at": 1716700500
    }
  ],
  "total": 87,
  "limit": 25,
  "offset": 0
}

Permission: principal_scope: "root-only". No cage requirements.

Tool: kaged.issue.get

Get full detail of a single issue, including its update stream.

Parameters:

{
  "number": 42
}
Param Type Required Description
number integer yes Project-scoped issue number (e.g., 42 for #42).

Returns:

{
  "id": "01HXYZ...",
  "number": 42,
  "title": "Add dark mode support",
  "body": "The UI should support a dark color scheme...",
  "original_body": null,
  "status": "triaged",
  "assignment": null,
  "created_by": "guest:01HXAB...",
  "created_at": 1716700000,
  "updated_at": 1716700500,
  "resolved_at": null,
  "resolved_by": null,
  "updates": [
    {
      "id": "01HXYZ...",
      "author": "operator",
      "kind": "status_change",
      "body": null,
      "metadata": { "old_status": "open", "new_status": "triaged" },
      "visibility": "all",
      "created_at": 1716700500
    }
  ]
}

Permission: principal_scope: "root-only". No cage requirements.

Behavior:

  • Issue not found → error issue_not_found.
  • Updates are returned in chronological order.
  • Updates with visibility: "operator_only" are included (the root agent is operator-equivalent for read purposes).

Tool: kaged.issue.create

File a new issue in the current project.

Parameters:

{
  "title": "Refactor auth module",
  "body": "The auth module has grown to 800 lines. Split into token validation, session management, and OAuth provider modules."
}
Param Type Required Description
title string yes Issue title. Max 200 characters.
body string yes Issue body in Markdown. Max 16 KB.

Returns:

{
  "id": "01HXYZ...",
  "number": 43,
  "status": "open",
  "created_at": 1716701000
}

Permission: principal_scope: "root-only". No cage requirements.

Behavior:

  • The created_by field is set to "agent:primary".
  • The issue number is auto-assigned (next sequential per project).
  • Title exceeding 200 characters → error invalid_params.
  • Body exceeding 16 KB → error invalid_params.

Tool: kaged.issue.update

Update an issue's title, body, or assignment.

Parameters:

{
  "number": 42,
  "body": "Rephrased: implement CSS custom property-based theming with prefers-color-scheme media query support.",
  "assignment": "primary"
}
Param Type Required Description
number integer yes Project-scoped issue number.
title string no New title. Max 200 characters.
body string no New body. Max 16 KB. On first edit, the original body is preserved in original_body.
assignment string no Assignment target: null (unassign), "primary", "workflow:<name>", or "session:<sid>".

Returns:

{
  "number": 42,
  "updated_fields": ["body", "assignment"],
  "updated_at": 1716701500
}

Permission: principal_scope: "root-only". No cage requirements.

Behavior:

  • At least one of title, body, assignment must be provided. Otherwise → error invalid_params.
  • Issue not found → error issue_not_found.
  • Body edit on an issue that has never been edited preserves the original in original_body. Subsequent edits do not overwrite original_body.
  • Assignment to a nonexistent workflow → error invalid_assignment with detail.
  • Each updated field generates an issue_updates row with the appropriate kind (title_edit, body_edit, or assignment_change).

Tool: kaged.issue.comment

Add a comment to an issue.

Parameters:

{
  "number": 42,
  "body": "Analysis complete. The auth module can be split into 3 files with no breaking changes to the public API.",
  "visibility": "all"
}
Param Type Required Description
number integer yes Project-scoped issue number.
body string yes Comment text in Markdown. Max 16 KB.
visibility enum no "all" (visible to everyone) or "operator_only" (internal note). Default: "all".

Returns:

{
  "update_id": "01HXYZ...",
  "issue_number": 42,
  "created_at": 1716702000
}

Permission: principal_scope: "root-only". No cage requirements.

Behavior:

  • Issue not found → error issue_not_found.
  • The author field on the update row is set to "agent:primary".
  • Empty body → error invalid_params.

Tool: kaged.issue.transition

Change an issue's status. Enforces the state machine defined in issues.md.

Parameters:

{
  "number": 42,
  "to": "assigned",
  "comment": "Assigning to primary for implementation."
}
Param Type Required Description
number integer yes Project-scoped issue number.
to enum yes Target status: triaged, assigned, in_progress, resolved, rejected, reopened.
comment string no Optional comment attached to the transition. Required when to is rejected.

Returns:

{
  "number": 42,
  "from": "triaged",
  "to": "assigned",
  "updated_at": 1716702500
}

Permission: principal_scope: "root-only". No cage requirements.

Behavior:

  • Issue not found → error issue_not_found.
  • Invalid transition (e.g., openresolved) → error invalid_transition with detail listing legal transitions from the current status.
  • rejected without a comment → error invalid_params with message "Rejection requires an explanatory comment."
  • resolved sets resolved_at and resolved_by to "agent:primary".
  • Each transition generates a status_change update row. If a comment is provided, a comment update row is also generated.

Tool: kaged.todo

Manage the working checklist on the session's bound issue. A single root-only tool with action-based dispatch, consistent with the unified kaged.issue shape. The tool operates implicitly on the bound issue — the agent never names the issue.

Per ADR-0034, todos are issue-owned (stored in the issue_todos table) and come in two kinds: step (agent working plan) and criterion (acceptance criteria). The single-in_progress invariant is enforced server-side.

Parameters:

{
  "action": "add",
  "items": ["Implement auth module", "Write tests", "Update docs"],
  "kind": "step"
}
Param Type Required Description
action enum yes One of: view, set, add, start, done, drop, note.
content string conditional Target task, addressed by text. Required for start, done, drop, note.
items string[] conditional Task contents for set/add.
phase string no Optional work-stage grouping for set/add.
kind enum no "step" (default) or "criterion". Used with set/add.
text string conditional Note body. Required for note.

Actions:

Action Description
view Return the current todo list (also returned after every mutation).
set Replace the working list. Abandons all existing todos, creates new ones from items.
add Append task(s) to the list.
start Mark a task in_progress. Enforces single-in_progress invariant (auto-demotes any existing in_progress to pending).
done Mark a task completed. For criterion kind, this is a claim, not an autonomous close.
drop Mark a task abandoned (not deleted). Agent cannot drop criterion todos — only the operator can.
note Append a note to a task.

Returns:

Every action returns the full current todo list, markdown-renderable:

{
  "todos": [
    { "id": "01HX...", "content": "Implement auth module", "status": "in_progress", "kind": "step", "phase": null, "position": 0 },
    { "id": "01HX...", "content": "Write tests", "status": "pending", "kind": "step", "phase": null, "position": 1 },
    { "id": "01HX...", "content": "All endpoints return 200", "status": "pending", "kind": "criterion", "phase": null, "position": 2 }
  ],
  "markdown": "- [>] Implement auth module\n- [ ] Write tests\n- [ ] All endpoints return 200 (criterion)"
}

Permission: principal_scope: "root-only". No cage requirements. Requires a bound issue on the session.

Behavior:

  • No bound issue → error no_bound_issue with guidance to ask the operator to bind an issue.
  • Task not found by content → error todo_not_found. Content-addressed lookup is case-insensitive, trimmed.
  • drop on a criterion todo → error invalid_transition with message "Only the operator can drop acceptance criteria."
  • start auto-demotes any existing in_progress todo to pending before promoting the target.
  • set abandons all existing todos (status → abandoned) before creating new ones.
  • note appends to the notes JSON array on the todo row.
  • done on a criterion sets completed_at but does not close the issue — closure requires operator checkpoint sign-off.

Todo bubble-up (ADR-0034)

Per ADR-0034 and ADR-0022 rule 10, subagents do not have kaged.todo tool access. The tool carries principal_scope: "root-only". Subagents are storage-blind and domain-blind — they never touch the issue tracker directly.

The todo bubble-up pattern is a sibling of the issue bubble-up pattern documented in issues.md:

  1. Subagent proposes. A subagent expresses a proposed checklist as structured content in its delegation return value.
  2. Root reviews. The root agent reviews the proposal: accept as-is, modify, reject, or escalate to the operator (via kaged.checkpoint / kaged.ask).
  3. Root persists. Only what the root accepts is persisted via kaged.todo, recorded with origin_agent set to the subagent's tree-path so the issue shows it as that subagent's sublist.
  4. Subagent sees only its slice. The subagent only ever sees the slice of the issue the root forwards in its delegation message — never the issue itself, never the full todo list.

Review policy is a per-agent knob (auto | review, default review). Whether the root reviews or auto-accepts a subagent's proposal is configurable, not hard-wired.


Tool: kaged.workflow.list

List workflows available in the current project.

Parameters:

{
  "invokable_by": "operator"
}
Param Type Required Description
invokable_by enum no Filter by invoker type: "operator", "guest". Omit for all workflows.

Returns:

{
  "workflows": [
    {
      "name": "deploy",
      "description": "Build and deploy the project to production.",
      "inputs": {
        "branch": { "type": "string", "required": true, "description": "Git branch to deploy." },
        "dry_run": { "type": "boolean", "required": false, "default": false }
      },
      "invokable_by": ["operator"],
      "timeout_seconds": 600
    }
  ],
  "count": 1
}

Permission: principal_scope: "root-only". No cage requirements.

Behavior:

  • Returns workflows from the loaded project DSL. This is a read from the in-memory compiled DSL, not a database query.
  • Each workflow's inputs schema is included so the agent can construct valid invocations.

Tool: kaged.workflow.trigger

Invoke a workflow with structured inputs.

Parameters:

{
  "name": "deploy",
  "inputs": {
    "branch": "main",
    "dry_run": true
  }
}
Param Type Required Description
name string yes Workflow name as declared in the project DSL.
inputs object yes Input values matching the workflow's inputs schema.

Returns:

{
  "run_id": "01HXYZ...",
  "workflow_name": "deploy",
  "status": "running",
  "created_at": 1716703000
}

Permission: principal_scope: "root-only". No cage requirements.

Behavior:

  • Workflow not found → error workflow_not_found.
  • Input validation failure → error invalid_params with per-field detail (same validation as the HTTP invoke endpoint).
  • The invoking principal is recorded as { type: "operator", id: "agent:primary" } — the root agent acts with operator authority.
  • The workflow run is created as a session with kind: "workflow" per workflows.md.
  • The tool returns immediately after the run is created. The run executes asynchronously. Use kaged.workflow.status to poll for completion.
  • file inputs are not supported via this tool (file uploads require the HTTP upload protocol). If the workflow has a required file input, the tool returns error file_input_not_supported with guidance to use the HTTP endpoint.

Tool: kaged.workflow.status

Get the status of a workflow run.

Parameters:

{
  "run_id": "01HXYZ..."
}
Param Type Required Description
run_id string yes Run ID returned by kaged.workflow.trigger.

Returns:

{
  "run_id": "01HXYZ...",
  "workflow_name": "deploy",
  "status": "succeeded",
  "inputs": { "branch": "main", "dry_run": true },
  "created_at": 1716703000,
  "completed_at": 1716703120,
  "duration_ms": 120000
}

Permission: principal_scope: "root-only". No cage requirements.

Behavior:

  • Run not found → error run_not_found.
  • status is one of: running, succeeded, failed, cancelled.
  • For failed runs, the response includes an additional error field with the failure reason.
  • For running runs, completed_at and duration_ms are null.

Shell tools

Design philosophy

The shell namespace provides direct command execution via the daemon's PTY broker. Unlike file or search tools that abstract over specific operations, shell tools give agents raw access to the host shell — gated by cage policy and capability requirements. This is intentionally a single tool (shell.bash); additional shells (e.g., shell.python, shell.node) may follow in v0.x but the execution model is the same.

Shell commands run in a PTY (not a raw subprocess), so they inherit the operator's environment and produce output that matches what the operator would see in a terminal. The broker manages process lifecycle including timeout enforcement and graceful kill escalation (SIGTERM → 5s grace → SIGKILL).

Tool: shell.bash

Execute a shell command via /bin/sh -c through the PTY broker.

Parameters:

{
  "command": "bun test --recursive packages/",
  "cwd": "./packages/daemon",
  "timeout": 30000,
  "env": { "NODE_ENV": "test" }
}
Param Type Required Description
command string yes Shell command to execute. Passed to /bin/sh -c. Max 64 KB.
cwd string no Working directory, relative to project root. Default: project root. Must resolve within the project tree (no .. escape).
timeout integer no Maximum execution time in milliseconds. Default: 120000 (2 minutes). Clamped to range [1000, 600000].
env object no Additional environment variables merged into the process environment. Keys must be non-empty strings; values must be strings. Max 64 entries.

Returns:

{
  "stdout": "bun test v1.2.3\n\n 42 pass\n 0 fail\n",
  "stderr": "",
  "exit_code": 0,
  "timed_out": false
}
Field Type Description
stdout string Combined stdout/stderr captured from the PTY scrollback. The PTY merges streams.
stderr string Always "" — PTY merges stdout and stderr into a single stream. Present for schema consistency with non-PTY backends.
exit_code integer Process exit code. 137 typically indicates SIGKILL (timeout).
timed_out boolean true if the process was killed due to timeout.

Permission: Requires { kind: "capability", name: "shell" }. Primary agent (no cage) is always allowed. Caged subagents must have shell in their capability set. Additionally requires { kind: "fs", mode: "rw", path: "caller" } — the caller must have read-write filesystem access to the working directory.

Behavior:

  • The command is spawned via PtyBroker.spawn() which uses Bun.spawn(["sh", "-c", command]).
  • Non-zero exit codes are returned as successful outcomes (data), not errors. The agent is expected to interpret exit codes.
  • If timeout is reached, the broker sends SIGTERM, waits 5 seconds (KILL_GRACE_MS), then sends SIGKILL. The response has timed_out: true.
  • Output is captured via the broker's scrollback buffer (getScrollback()). Output exceeding 1 MB is truncated with a trailing \n[output truncated — 1 MB limit] marker.
  • Empty command → error invalid_params.
  • cwd resolving outside the project root → error invalid_params with detail.
  • env keys containing = or empty strings → error invalid_params.
  • The process inherits the daemon's environment, with env entries merged on top (overriding on collision).

Sandbox integration

Permission enforcement

Every tool call passes through ToolPermissions before execution. The permission check is based on the caller's cage_policy:

// Conceptual
function checkPermission(
  tool: ToolDefinition,
  params: Record<string, unknown>,
  context: ToolCallContext
): "allowed" | PermissionError {
  // Primary agent has no cage — always allowed
  if (context.cage_policy === null) return "allowed";

  const policy = context.cage_policy;

  for (const req of tool.requires) {
    switch (req.kind) {
      case "fs": {
        const requestedPath = resolve(context.project_root, params.path);
        const mode = req.mode;
        const allowed = policy.fs.some(mount =>
          requestedPath.startsWith(resolve(context.project_root, mount.path)) &&
          (mode === "ro" || mount.mode === "rw")
        );
        if (!allowed) return { code: "capability_denied", detail: `${mode}:fs:${params.path}` };
        break;
      }
      case "seccomp": {
        if (policy.seccomp !== req.profile) {
          return { code: "seccomp_insufficient", detail: `requires ${req.profile}, have ${policy.seccomp}` };
        }
        break;
      }
    }
  }

  return "allowed";
}

Result filtering

For read-only tools that return results across the workspace (e.g., search.grep, code.lsp references action), the bridge returns all results but marks those outside the caller's cage:

  • Inside cage: full result with content previews.
  • Outside cage: path and line number only. No content preview. Marked "outside_cage": true.

This gives agents workspace-wide awareness (they can see that a function is used in 15 files) without leaking file contents they shouldn't read.

Tool availability by cage type

Tool cage: disabled / primary default seccomp relaxed seccomp
file.read all files cage fs entries (ro or rw) cage fs entries (ro or rw)
file.write / edit.text all files cage fs entries (rw only) cage fs entries (rw only)
search.grep / search.glob project root cage fs entries (ro or rw) cage fs entries (ro or rw)
search.ast project root cage fs entries (ro or rw) cage fs entries (ro or rw)
code.lsp (read actions) all all (results filtered) all (results filtered)
code.lsp (rename action) all files cage fs entries (rw) for all affected files cage fs entries (rw) for all affected files
debug (launch — WebSocket debugger, e.g. Bun) yes yes yes
debug (launch — ptrace-based debugger) yes no — error with guidance yes
debug (attach) yes depends on debugger type yes
shell.bash yes (capability: shell) cage fs entries (rw) + shell capability cage fs entries (rw) + shell capability
kaged.issue.* root agent only noprincipal_scope rejection noprincipal_scope rejection
kaged.todo root agent only noprincipal_scope rejection noprincipal_scope rejection
kaged.workflow.* root agent only noprincipal_scope rejection noprincipal_scope rejection

Failure modes

Failure Detection Recovery Agent impact
Rust FFI library not found Daemon startup Daemon refuses to start None — fatal startup gate
Language server binary not found First code.lsp call for that language Error returned to agent Agent sees lsp_server_not_found with install instructions. Can continue without LSP.
Language server crashes EOF on stdio Bridge restarts server (up to 3 times, then gives up for this session) Agent sees lsp_server_unavailable on next call. Retry after bridge restart.
Language server hangs Request timeout (30s) Bridge kills server, restarts Agent sees timeout error. Next call triggers restart.
Debug adapter crashes EOF on stdio or DAP terminated event Debug session marked ended Agent sees dap_session_ended. Must call debug with action: "launch" again.
Debugged process exits unexpectedly DAP exited event Debug session transitions to exited state Agent sees status: "exited" on next step/continue.
File edit conflict (file changed between read and edit) Content hash mismatch Edit rejected Agent sees file_changed_since_read. Must re-read and retry.
Permission denied (cage) ToolPermissions check Tool call rejected Agent sees capability_denied with detail on what's needed.
search.grep timeout (60s) Watchdog Results returned as partial with truncated: true Agent gets partial results. Can narrow the search.
LSP rename affects files outside cage Pre-check of edit scope Rename rejected before any changes Agent sees rename_scope_exceeds_cage with list of inaccessible files.
AST pattern invalid ast-grep parse error Error returned Agent sees invalid_pattern with syntax guidance.
Concurrent tool calls to same file In-memory file lock Second call waits (up to 5s, then error) Agent sees file_busy if timeout exceeded.
principal_scope violation (non-root calls kaged.*) Registry dispatch check Tool call rejected Agent sees principal_scope_violation. DSL parser should have caught this at load time; runtime check is defense-in-depth.
Issue not found Database lookup Error returned Agent sees issue_not_found with the requested number.
Invalid issue transition State machine check Error returned Agent sees invalid_transition with legal transitions from current status.
No bound issue for kaged.todo Session lookup Error returned Agent sees no_bound_issue with guidance to ask operator to bind an issue.
Todo not found by content Content-addressed lookup Error returned Agent sees todo_not_found with the searched content.
Agent drops criterion todo Kind check in handler Error returned Agent sees invalid_transition — only operator can drop criteria.
Workflow not found DSL lookup Error returned Agent sees workflow_not_found with the requested name.
Workflow input validation failure Zod schema check Error returned Agent sees invalid_params with per-field detail.
Workflow file input via tool Input type check Error returned Agent sees file_input_not_supported — file uploads require HTTP endpoint.
Workflow run not found Database lookup Error returned Agent sees run_not_found with the requested run ID.
Shell command empty Parameter validation Error returned Agent sees invalid_params — command is required.
Shell cwd escapes project root Path resolution check Error returned Agent sees invalid_params with detail on the resolved path.
Shell command timeout PTY broker watchdog Process killed (SIGTERM → SIGKILL) Agent sees result with timed_out: true and exit_code: 137.
Shell output exceeds 1 MB Scrollback size check Output truncated Agent sees truncated output with [output truncated — 1 MB limit] marker.
Shell spawn failure Bun.spawn error Error returned Agent sees shell_spawn_failed with system error detail.

Audit events

Event When Data
tool.called Every tool invocation tool_name, caller, session_id, run_id, request_id, params (redacted for large content)
tool.completed Tool returns result tool_name, request_id, duration_ms, success
tool.denied Permission check failed tool_name, caller, denied_capability, cage_summary
code.lsp.server_spawned Language server started language, command, project_id
code.lsp.server_crashed Language server died unexpectedly language, exit_code, restart_count
code.lsp.server_stopped Language server shut down cleanly language, reason (idle_timeout, project_unload, daemon_shutdown)
debug.session_started Debug session created debug_session_id, runtime, script, caller
debug.session_ended Debug session terminated debug_session_id, reason (disconnect, crash, run_ended)
debug.breakpoint_hit Execution paused at breakpoint debug_session_id, breakpoint_id, path, line
file.written File created or overwritten path, bytes, caller
file.edited File edited path, replacements, caller
kaged.issue.created Root agent files an issue issue_id, project_id, number, title, caller
kaged.issue.updated Root agent updates an issue issue_id, updated_fields, caller
kaged.issue.commented Root agent comments on an issue issue_id, update_id, visibility, caller
kaged.issue.transitioned Root agent changes issue status issue_id, from_status, to_status, caller
kaged.todo.set Root agent replaces todo list issue_id, count, kind, caller
kaged.todo.added Root agent adds todos issue_id, count, kind, caller
kaged.todo.started Root agent starts a todo issue_id, todo_id, content, caller
kaged.todo.completed Root agent completes a todo issue_id, todo_id, content, kind, caller
kaged.todo.dropped Root agent abandons a todo issue_id, todo_id, content, caller
kaged.todo.noted Root agent appends a note issue_id, todo_id, caller
kaged.workflow.triggered Root agent triggers a workflow run_id, workflow_name, inputs (redacted for large values), caller
kaged.workflow.status_checked Root agent polls workflow run status run_id, workflow_name, status, caller
shell.executed Shell command executed command (truncated to 200 chars), cwd, exit_code, timed_out, duration_ms, caller

Testing notes

File tool tests

  • file.read happy path: read a known file, assert line-numbered content.
  • file.read binary detection: read a .png, assert type: "binary".
  • file.read offset/limit: read lines 10-20 of a 100-line file.
  • file.write creates parent dirs: write to ./a/b/c/new.ts, assert directories created.
  • file.write requires prior read: write without reading first, assert file_not_read.
  • edit.text exact match: edit a known string, assert replacement.
  • edit.text multiple matches: old_string appears 3 times, replace_all false, assert multiple_matches.
  • edit.text no match: old_string not in file, assert old_string_not_found.
  • file.create existing file: file exists, assert file_exists.

Search tool tests

  • search.grep regex match: search for TODO, assert matching lines.
  • search.grep file filter: search *.ts only, assert no .js results.
  • search.grep timeout: search a huge directory with pathological regex, assert timeout with partial results.
  • search.glob pattern: **/*.test.ts, assert only test files.
  • search.ast meta-variable: pattern console.log($MSG), assert matches with captured $MSG.
  • edit.ast dry-run preview: pattern console.log($MSG)logger.debug($MSG), dry_run: true, assert applied: false and changes array populated.
  • edit.ast apply: same pattern with dry_run: false, assert applied: true and file on disk modified.
  • edit.ast path resolution: single file path, assert only that file touched; directory path, assert all matching files.
  • edit.ast invalid pattern: unparseable ast-grep pattern, assert invalid_pattern.
  • edit.ast language inference: mixed .ts and .js files without explicit lang, assert inference error or successful inference when uniform.
  • edit.ast max_replacements cap: set max_replacements: 1 with 3 matches, assert limit_reached: true and only 1 replacement.
  • edit.ast max_files cap: set max_files: 1 with 2 matching files, assert limit_reached: true.
  • edit.ast path traversal rejection: pass path: "../../etc", assert invalid_params.
  • edit.ast parse error collection: file with syntax errors among candidates, assert parse_errors populated and operation continues on parseable files.

code.lsp tool tests

  • Server spawn on demand: first code.lsp diagnostics call spawns the server, assert server process exists.
  • diagnostics returns errors: file with a type error, assert diagnostic with correct line/message.
  • definition navigates: cursor on a function call, assert definition in the correct file.
  • references finds usages: cursor on a type, assert all usage locations.
  • rename atomic: rename a symbol used in 3 files. Assert all 3 updated. Introduce a write failure on file 2 — assert all 3 rolled back.
  • rename cage scoping: rename a symbol used in files inside and outside the cage. Assert rejection before any edits.
  • File sync: agent edits a file via edit.text, then calls code.lsp diagnostics. Assert the diagnostics reflect the edit (not stale).
  • Server crash recovery: kill the language server mid-request. Assert the bridge restarts it and the next call succeeds.
  • Idle timeout: call code.lsp diagnostics, wait 10+ minutes, call again. Assert server was stopped and re-spawned.

debug tool tests

  • launch + breakpoint + continue: launch a script, set a breakpoint, continue, assert stopped at breakpoint.
  • variables inspection: at a breakpoint, inspect local variables. Assert correct names, values, types.
  • evaluate watch: evaluate an expression at a breakpoint. Assert result.
  • step_into/step_over/step_out: at a breakpoint, step over a function call, assert cursor moves to next line. Step into, assert cursor inside the function.
  • disconnect cleanup: disconnect, assert debug adapter process is gone and debuggee is killed.
  • Auto-cleanup on run end: cancel a run while a debug session is active. Assert debug session is cleaned up.
  • default seccomp rejection: subagent in default cage calls debug with action: "launch" with ptrace-based debugger. Assert dap_requires_relaxed_seccomp.
  • Bun --inspect works under default seccomp: Bun's WebSocket debugger doesn't need ptrace. Assert debug launch succeeds.

Permission tests

  • Cage read enforcement: subagent with fs: [{mode: ro, path: ./src}] reads ./src/main.ts (ok), reads ./data/secrets.json (denied).
  • Cage write enforcement: subagent with fs: [{mode: ro, path: ./src}] tries edit.text on ./src/main.ts (denied — ro, not rw).
  • Primary has no cage: primary calls any tool on any path — all succeed.
  • Search result filtering: subagent with restricted cage calls search.grep on project root. Results outside cage have no content preview.

Project management tool tests

  • kaged.issue.list happy path: project has 3 issues, assert all returned with correct fields.
  • kaged.issue.list status filter: filter by triaged, assert only triaged issues returned.
  • kaged.issue.list search: FTS5 query matches title and body content.
  • kaged.issue.list pagination: 30 issues, limit: 10, offset: 10, assert correct slice.
  • kaged.issue.get happy path: get issue #42, assert full detail including updates.
  • kaged.issue.get not found: get nonexistent issue, assert issue_not_found.
  • kaged.issue.get includes operator-only updates: assert visibility: "operator_only" updates are included for root agent.
  • kaged.issue.create happy path: create issue, assert sequential number assigned and status is open.
  • kaged.issue.create title too long: 201 characters, assert invalid_params.
  • kaged.issue.create author identity: assert created_by is "agent:primary".
  • kaged.issue.update body edit: update body, assert original_body preserved on first edit.
  • kaged.issue.update second body edit: update body again, assert original_body unchanged from first edit.
  • kaged.issue.update assignment: assign to "workflow:deploy", assert assignment recorded.
  • kaged.issue.update invalid assignment: assign to nonexistent workflow, assert invalid_assignment.
  • kaged.issue.comment happy path: add comment, assert update row created with kind: "comment".
  • kaged.issue.comment operator-only: add with visibility: "operator_only", assert visibility stored.
  • kaged.issue.transition happy path: opentriaged, assert status changed and update row created.
  • kaged.issue.transition invalid: openresolved, assert invalid_transition with legal transitions.
  • kaged.issue.transition rejected requires comment: transition to rejected without comment, assert invalid_params.
  • kaged.issue.transition resolved sets timestamps: transition to resolved, assert resolved_at and resolved_by set.

kaged.todo tool tests

  • view happy path: session has bound issue with 3 todos, assert all returned with correct fields and markdown.
  • view no bound issue: session has no bound issue, assert no_bound_issue error.
  • set replaces list: set 3 items, assert existing todos abandoned and new ones created.
  • set with kind: set items with kind: "criterion", assert kind persisted.
  • set with phase: set items with phase: "discovery", assert phase persisted.
  • add appends: add 2 items to existing list, assert position continues from max.
  • start promotes: start a pending todo, assert status in_progress.
  • start single-in_progress: start a second todo while one is in_progress, assert first demoted to pending.
  • done completes: mark a todo done, assert completed_at set.
  • done criterion is a claim: mark a criterion done, assert completed_at set but issue not resolved.
  • drop abandons: drop a step todo, assert status abandoned.
  • drop criterion rejected: drop a criterion todo, assert invalid_transition error.
  • note appends: add a note, assert notes array grows.
  • note max length: note exceeds 2000 chars, assert invalid_params.
  • Content-addressed lookup: start a todo by partial text match, assert correct todo found.
  • Content not found: start a todo with nonexistent content, assert todo_not_found.

kaged.workflow.list happy path:** project has 2 workflows, assert both returned with input schemas.

  • kaged.workflow.list filter: filter by invokable_by: "guest", assert only guest-invokable workflows returned.
  • kaged.workflow.trigger happy path: trigger deploy with valid inputs, assert run_id returned and status is running.
  • kaged.workflow.trigger not found: trigger nonexistent workflow, assert workflow_not_found.
  • kaged.workflow.trigger invalid inputs: missing required input, assert invalid_params with per-field detail.
  • kaged.workflow.trigger file input: workflow has required file input, assert file_input_not_supported.
  • kaged.workflow.trigger invoker identity: assert invoking principal recorded as { type: "operator", id: "agent:primary" }.
  • kaged.workflow.status happy path: poll completed run, assert succeeded with duration.
  • kaged.workflow.status running: poll in-progress run, assert completed_at and duration_ms are null.
  • kaged.workflow.status failed: poll failed run, assert error field present.
  • kaged.workflow.status not found: poll nonexistent run, assert run_not_found.

Principal scope enforcement tests

  • Root agent calls kaged.issue.list: assert success.
  • Non-root agent calls kaged.issue.list: assert principal_scope_violation at dispatch time.
  • DSL with kaged.issue.create on subagent: assert parse-time rejection (schema error).
  • DSL with kaged.workflow.trigger on subagent: assert parse-time rejection.
  • Per-agent tool resolution: root agent with no tools: override, assert kaged.issue.* and kaged.workflow.* enabled by default.
  • Per-agent tool resolution: subagent with no tools: override, assert empty tool set (no defaults).
  • Per-agent tool resolution: subagent with explicit "file.*": { enabled: true }, assert only file tools enabled.
  • Root agent suppresses default: root agent with "kaged.issue.*": { enabled: false }, assert issue tools disabled.

Shell tool tests

  • shell.bash happy path: execute echo hello, assert stdout contains hello, exit_code is 0, timed_out is false.
  • shell.bash non-zero exit: execute exit 42, assert exit_code is 42, result is success (not error).
  • shell.bash custom cwd: execute pwd with cwd: "./packages/daemon", assert output contains packages/daemon.
  • shell.bash cwd escape: execute with cwd: "../../", assert invalid_params error.
  • shell.bash timeout: execute sleep 60 with timeout: 2000, assert timed_out is true and exit_code is 137.
  • shell.bash timeout clamping: pass timeout: 500, assert clamped to 1000.
  • shell.bash timeout clamping upper: pass timeout: 999999, assert clamped to 600000.
  • shell.bash empty command: pass command: "", assert invalid_params.
  • shell.bash env vars: execute echo $FOO with env: { "FOO": "bar" }, assert output contains bar.
  • shell.bash env validation: pass env: { "": "val" }, assert invalid_params.
  • shell.bash output truncation: generate >1 MB output, assert truncated with marker.

Open questions

  1. Structural rewrite tool (search.ast_replace). Resolved: exposed as edit.ast (dry-run by default, requires explicit dry_run: false to apply). Tool definition and handler wired in packages/daemon/src/runtime/tool-handlers/edit-handlers.ts, using the existing astEdit Rust N-API export. Tests cover dry-run, path resolution, and rewrite application.

  2. LSP multi-root workspaces. Some language servers support multi-root workspaces. kaged projects are single-root by design. If a subagent needs LSP for a dependency (e.g., node_modules), the current model handles it (the server sees the whole project tree). But monorepo projects with multiple kaged projects may want a shared server. Deferred.

  3. DAP for compiled languages. Debugging Go, Rust, or C++ requires delve, lldb, or gdb — which have their own DAP adapters. v0 supports interpreted runtimes (Bun, Node, Python). Compiled-language DAP adapters are v0.x.

  4. Tool versioning. Built-in tools don't version today (they're part of the daemon). If we change a tool's parameter schema, agents with cached tool definitions may break. A tool_version field in the registry is plausible. Not v0.

  5. Concurrent LSP requests. Language servers vary in their concurrency support. Some handle concurrent requests; some serialize. The bridge should respect the server's capabilities (from the initialize response). v0: serialize all requests to a given server. v0.x: parallel where the server supports it.

  6. DAP logpoints as a linter-adjacent workflow. Logpoints (debug with action: "set_breakpoint" and log_message) are essentially structured console.log without modifying source. Should the tooling layer encourage agents to use logpoints before full breakpoint debugging? Possibly — but this is an agent-prompting concern, not a tool-layer concern.

  7. File watching. Should the daemon watch the project filesystem for external changes (operator editing files in their IDE) and notify agents / update LSP? v0: no. The daemon trusts its own tool calls as the source of truth. External changes are invisible until the agent re-reads the file. v0.x may add inotify-based sync.

  8. Tool usage quotas. Should there be per-run or per-session limits on tool calls (e.g., max 1000 file reads per run)? v0: no hard limits. The walltime limit on cages is the indirect bound. If agents abuse tools, the operator adjusts the walltime or intervenes at a checkpoint.

Amendments

2026-05-26 — ADR-0022: per-agent tools, kaged.issue.*/kaged.workflow.* namespaces, principal_scope

Per ADR-0022:

  • ToolDefinition gains principal_scope?: "root-only" field. Tools with this field are rejected by the DSL parser on non-root agents and by the registry at dispatch time as defense-in-depth.
  • Namespace table rewritten. The former kaged.* reserved row is replaced by two concrete namespaces: kaged.issue (6 tools: create, update, comment, transition, list, get) and kaged.workflow (3 tools: trigger, list, status). Both carry principal_scope: "root-only".
  • ToolCallContext.caller now encodes tree-position path (e.g. "primary", "primary.subagents.scraper"). Root-only check is caller === "primary". cage_policy is null for agents with cage: disabled (was: "for primary").
  • Per-agent tool resolution section added. Documents the resolution chain: built-in registry → role-based defaults → agent's tools: block → principal_scope enforcement → cage filter at dispatch. Project-level tools: block no longer exists; each agent declares its own tool surface.
  • kaged.issue.* tool definitions added: kaged.issue.list, kaged.issue.get, kaged.issue.create, kaged.issue.update, kaged.issue.comment, kaged.issue.transition. Full parameter/return schemas, behavior, and error codes.
  • kaged.workflow.* tool definitions added: kaged.workflow.list, kaged.workflow.trigger, kaged.workflow.status. Full parameter/return schemas, behavior, and error codes.
  • Failure modes table extended with principal_scope_violation, issue/workflow not-found errors, invalid transitions, input validation failures, and file_input_not_supported.
  • Audit events table extended with kaged.issue.created, kaged.issue.updated, kaged.issue.commented, kaged.issue.transitioned, kaged.workflow.triggered, kaged.workflow.status_checked.
  • Tool availability table extended with kaged.issue.* and kaged.workflow.* rows (root agent only; principal_scope rejection for all other agents regardless of cage type).
  • Testing notes extended with project management tool tests (31 cases) and principal scope enforcement tests (8 cases).

2026-05-27 — Bridge wiring complete: per-project cache, daemon shutdown, 87 handler tests

All 24 built-in tool handlers are now wired end-to-end in the daemon runtime. This amendment documents the implementation that closes the gap between the spec's bridge architecture sections and running code.

  • @kaged/natives package created. Rust N-API crate (kaged-natives) with 14 exports (grep, glob, fuzzyFind, astGrep, astEdit, summarizeCode, listWorkspace, search, hasMatch, invalidateFsScanCache, getWorkProfile, AstMatchStrictness, FileType, GrepOutputMode). Vendored kaged-ast crate (from oh-my-pi's pi-ast). Platform-aware TS loader with lazy singleton. Release binary: 93MB (LTO fat + strip + panic abort, 50+ tree-sitter grammars).
  • File handlers (file-handlers.ts). 4 handlers (file.read, file.write, edit.text, file.create) using pure Bun I/O. Path-traversal security, binary detection, pagination.
  • Search handlers (search-handlers.ts). 3 handlers (search.grep, search.glob, search.ast_grep) calling @kaged/natives Rust N-API. PathCheck discriminated union with ToolErrorCode-typed codes. 18 tests.
  • LSP handlers (lsp-handlers.ts). Unified code.lsp handler with action dispatch (14 actions: diagnostics, definition, references, symbols, rename, rename_file, code_actions, hover, type_definition, implementation, status, reload, capabilities, request) using LspBridge interface. PathCheck pattern with ToolErrorCode. 57 tests.
  • DAP handlers (dap-handlers.ts). Unified debug handler with action dispatch (27 actions: launch, attach, set_breakpoint, remove_breakpoint, list_breakpoints, set_instruction_breakpoint, remove_instruction_breakpoint, data_breakpoint_info, set_data_breakpoint, remove_data_breakpoint, step_into, step_over, step_out, continue, pause, stack_trace, threads, scopes, variables, evaluate, disassemble, read_memory, write_memory, modules, loaded_sources, custom_request, disconnect) using DapBridge interface. Throws ToolCallError("dap_session_ended") when no bridge. 30 tests.
  • LSP bridge runtime implemented per § LSP bridge architecture. lsp-jsonrpc.ts (Content-Length framing, JsonRpcParser, serializeMessage), lsp-client.ts (LspClientBun.spawn process lifecycle, JSON-RPC request/response correlation, initialize/initialized handshake, textDocument/didOpen/didChange file sync, publishDiagnostics cache, idle tracking, graceful shutdown), lsp-bridge-runtime.ts (LspBridgeRuntime — file-extension→server routing, on-demand client spawning, 10-min idle checker, all 8 bridge methods). 14 JSON-RPC tests.
  • DAP bridge runtime implemented per § DAP bridge architecture. dap-client.ts (DapClientBun.spawn, DAP wire protocol with Content-Length framing + seq-based request/response, initialize/launch/attach/configurationDone sequence, event routing), dap-bridge-runtime.ts (DapBridgeRuntime — runtime adapter resolution for node/bun, all 9 bridge methods).
  • Per-project bridge cache (bridge-cache.ts). getBridgesForProject(projectRoot) lazily creates LspBridgeRuntime + DapBridgeRuntime per project root. disposeAllBridges() for daemon shutdown. disposeBridgesForProject() for project unload.
  • Daemon wiring. primary-runner.ts calls getBridgesForProject() at dispatch time and injects lspBridge/dapBridge into registerToolHandlers via ToolHandlerDeps. main.ts calls disposeAllBridges() in the shutdown sequence (after daemonServer.stop(), before broker.disposeAll()).
  • Implements header updated. Changed from packages/agent-tooling/ (planned) to packages/agent-tooling/, packages/natives/, daemon tool handlers in packages/daemon/src/runtime/tool-handlers/.
  • Remaining gaps. File sync tracker (LSP didChange notifications for external edits), audit event emission for tool calls. Both are v0.x concerns — the runtime is functional without them.

2026-05-27 — Issue tools, interaction tools, principal_scope enforcement implemented

Three new tool families implemented across @kaged/agent-tooling, @kaged/harness, and packages/daemon:

  • kaged.issue.* tools implemented (5 tools, not 6). kaged.issue.create, kaged.issue.list, kaged.issue.get, kaged.issue.comment, kaged.issue.transition. The spec's kaged.issue.update was dropped — agents cannot edit base issue info (title/body/assignment); they can only comment and transition status. This is an additive-only design: agents file issues, comment on them, and move them through the state machine. Transition requires a comment for rejected and resolved statuses.
  • kaged.ask + kaged.form interaction tools implemented (2 tools). Structured multi-question interaction (kaged.ask: questions array with id, title, description, options[], multiple flag) and dynamic data collection with file uploads (kaged.form: fields array with name, label, type, required, description; file uploads land in config:/tmp/<requestId>/). Both use the checkpoint-like pause/resume flow: session pauses → WS event → operator answers → new run.
  • principal_scope: "root-only" enforcement implemented. ToolDefinition field added; ToolRegistry.dispatch() checks caller === "primary" before permission checks, returns principal_scope_violation error code. All 7 new tools (kaged.issue.* × 5 + kaged.ask + kaged.form) carry principal_scope: "root-only".
  • Issue handler implementation (kaged-issue-handlers.ts). 5 handlers with IssueHandlerDeps (storage adapter dependency). Transition state machine: open→{triaged, assigned, in_progress, resolved, rejected}; resolved/rejected→{reopened}; reopened→same as open. Author tracking via agent:primary. resolvedAt/resolvedBy lifecycle on resolved status. 31 handler tests (70 expect() calls).
  • Interaction signal flow in @kaged/harness. InteractionRequested type + InteractionKind ("ask" | "form") added to RunPrimaryResult. Mastra tools built in runPrimary closure with mutable interactionSignal, injected via kagedToolOverrides. Daemon handles interactionRequested signal via transitionSessionToCheckpoint with "interaction:" detail prefix. 5 new runtime tests.
  • Config scaffolding. .kaged/.gitignore (ignores tmp/ and *.local.*) and .kaged/tmp/ directory created in initProjectDir.
  • Namespace table updated. kaged namespace now has 8 tools: checkpoint (1) + issue (5) + interaction (2). Spec kaged.issue.update section retained for reference but the tool is not implemented — intentional deviation documented above.
  • Error taxonomy extended. ToolErrorCode gains issue_not_found, invalid_transition, principal_scope_violation.
  • ToolHandlerDeps extended with storage: StorageAdapter for issue handlers. registerToolHandlers wires all 31 tools (24 original + 5 issue + 2 interaction, though interaction tools are wired at the Mastra level in the harness, not via registerToolHandlers).
  • Test counts. agent-tooling: 141 tests (was 136). harness: 127 tests (was 122). daemon: 862 tests (was 831). Total: 3,119 (was 3,065).

2026-06-04 — shell.bash tool: PTY-backed command execution

New shell namespace with a single tool for agent-driven command execution:

  • shell.bash tool added. Execute shell commands via the daemon's PTY broker (PtyBroker.spawn()). Parameters: command (required), cwd, timeout, env. Returns: stdout, stderr (always empty — PTY merges streams), exit_code, timed_out. Non-zero exit codes are data, not errors.
  • Namespace table extended. shell added as 6th built-in namespace. Reserved namespace list updated.
  • ToolDefinition.namespace union extended with "shell".
  • Permission model. Requires { kind: "capability", name: "shell" } + { kind: "fs", mode: "rw", path: "caller" }. Primary agent always allowed; caged subagents need explicit shell capability.
  • PTY broker integration. Handler delegates to existing PtyBroker (threaded via ToolHandlerDeps.ptyBroker, not the getBrokerRef() singleton). Timeout enforcement uses broker's SIGTERM → 5s grace → SIGKILL escalation.
  • Tool availability table extended with shell.bash row.
  • Failure modes table extended with shell-specific failures: empty command, cwd escape, timeout, output truncation, spawn failure.
  • Audit events extended with shell.executed.
  • Testing notes extended with 11 shell tool test cases.

2026-06-05 — ADR-0034: kaged.todo tool, todo bubble-up

Per ADR-0034:

  • kaged.todo tool added. Single root-only tool with 7 actions (view, set, add, start, done, drop, note). Operates implicitly on the session's bound issue. Content-addressed task lookup. Single-in_progress invariant enforced server-side. Two kinds: step (agent working plan) and criterion (acceptance criteria). Agent cannot drop criteria — only the operator can.
  • Namespace table extended. kaged namespace now has 5 tool groups: checkpoint (1) + issue (5) + todo (1) + interaction (2) + workflow (3).
  • Todo bubble-up section added. Documents how subagents propose checklists via delegation return values, the root reviews and persists via kaged.todo. Sibling of the issue bubble-up pattern.
  • Tool availability table extended with kaged.todo row (root agent only; principal_scope rejection for all other agents).
  • Failure modes table extended with no_bound_issue, todo_not_found, and criterion drop rejection.
  • Audit events extended with kaged.todo.set, kaged.todo.added, kaged.todo.started, kaged.todo.completed, kaged.todo.dropped, kaged.todo.noted.
  • Testing notes extended with 16 kaged.todo test cases.
  • Error taxonomy extended. ToolErrorCode gains no_bound_issue, todo_not_found, session_not_found.
  • Constrained-by list extended with ADR-0034.

2026-06-05: Live todo surfacing: sticky reminder, echo notes, content-addressing coaching

  • Sticky reminder. A new ephemeral system-role message is injected near the tail of the messages array before each LLM call when the session has a bound issue with open todos. The reminder lists up to 5 open items (always including in_progress), formatted as a markdown checklist. It is not persisted, regenerated fresh each step. Suppressed when the immediately preceding turn already carries a kaged.todo tool result (preventing redundancy). Implemented in packages/daemon/src/runtime/sticky-todo-reminder.ts, wired into primary-runner.ts.
  • Echo notes. The markdown output returned by every kaged.todo mutation now includes → note lines beneath the in_progress task's notes, giving the agent immediate visibility into its own scratch notes.
  • Content-addressing coaching. Two new error behaviors on start, done, drop, note actions:
    • ID-like token rejection. If the content parameter matches /^(task|todo|item|step|id)-\d+$/i, the tool returns error code content_addressing_hint with guidance to use the task's full text instead of a synthetic ID.
    • Enhanced not-found. When content-addressed lookup fails, the todo_not_found error now includes a hint listing available items when the list is non-empty, or guidance that the list is empty when it is.
  • content_addressing_hint error code added to ToolErrorCode union in @kaged/agent-tooling types.
  • Failure modes table gains a row: | Agent uses ID-like token for todo content | Pattern match in handler | Error returned | Agent sees \content_addressing_hint` with guidance to use full task text. |`
  • Testing notes kaged.todo section gains: content-addressing coaching tests (ID-like rejection, enhanced not-found with empty-list hint), sticky reminder tests (suppression, windowing, cap at 5).

2026-06-06 — edit.ast tool: AST-aware structural rewrite via ast-grep

  • edit.ast tool implemented. Exposes the existing Rust N-API astEdit function as a daemon tool. Unified namespace: edit (not search), pairing with edit.text as the two editing modes (exact-string vs. structural). Dry-run by default (dry_run: true); agents must explicitly set dry_run: false to apply changes to disk.
  • Tool definition added. EDIT_AST in packages/agent-tooling/src/builtins/file-tools.ts (or separate edit-tools.ts). Parameters: rewrites (required), lang, path, glob, dry_run, max_replacements, max_files. Returns: changes, file_changes, total_replacements, files_touched, files_searched, applied, limit_reached, parse_errors.
  • Handler added. createEditAstHandler() in packages/daemon/src/runtime/tool-handlers/edit-handlers.ts (or search-handlers.ts). Resolves project-root-relative path, validates rewrites is a non-empty object, calls natives.astEdit() with dry_run: true by default, transforms native AstReplaceResult to daemon response shape.
  • Registration. registerToolHandlers wires edit.ast alongside search.grep/search.glob/search.ast. ToolHandlerDeps unchanged.
  • Default tools updated. edit.ast added to DEFAULT_ROOT_TOOLS in packages/dsl/src/defaults.ts (disabled by default, opt-in like edit.text).
  • Spec updates. agent-tooling.md § Namespace table: edit namespace now has 2 tools (edit.text, edit.ast). Adoption table: ast_editsearch.ast / edit.ast (adapt). Open question #1 resolved. Testing notes: 10 new edit.ast test cases.
  • Test updates. builtins.test.ts: FILE_TOOLS count 4→5, ALL_BUILTIN_TOOLS 17→18. New edit-handlers.test.ts (or search-handlers.test.ts extension): 10 tests covering dry-run, apply, path resolution, invalid pattern, language inference, max_replacements, max_files, path traversal, parse error collection, single-file vs directory.

References