# Cabinet Developer Reference Wiki

> Cabinet is an AI-first "startup OS" that stores an entire knowledge base as markdown files on disk, orchestrates BYOAI agents (Claude, Codex, Gemini, Grok, and more) with persistent memory and cron-scheduled jobs, and exposes a Next.js + WebSocket daemon architecture with zero database dependency.

## Context Links

- [Agent index](https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/llms.txt)
- [Human interactive wiki](https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59)
- [GitHub repository](https://github.com/hilash/cabinet)

## Repository Metadata

- Repository: hilash/cabinet

- Generated: 2026-05-27T06:37:40.526Z
- Updated: 2026-05-27T07:02:29.053Z
- Runtime: Pi · Claude Code · claude-sonnet-4-6:high
- Format: Technical
- Pages: 6

## Page Index

- 01. [Technical Orientation](https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/01-technical-orientation.md) - What Cabinet is, its two-process runtime (Next.js on port 4000 + daemon on port 4100), the file-on-disk data model, the npx CLI entry points (cabinetai / create-cabinet), the full tech stack (Next.js 16, TypeScript, Tiptap, Zustand, xterm.js, node-cron, better-sqlite3, ws), and how the rest of this reference is organized.
- 02. [Cabinet Daemon — WebSocket Bus, PTY Sessions & Job Scheduler](https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/02-cabinet-daemon-websocket-bus-pty-sessions-job-scheduler.md) - The unified background server (server/cabinet-daemon.ts) that combines the WebSocket event bus, node-cron job scheduler, PTY/xterm terminal sessions with Claude lifecycle management, full-text search indexing, SQLite initialization, and file-watcher-driven real-time updates. Covers the daemon HTTP + WS server boot sequence, message protocol, and how each subsystem registers with the single server instance.
- 03. [Provider Adapter Layer — BYOAI Adapters & Registries](https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/03-provider-adapter-layer-byoai-adapters-registries.md) - The pluggable adapter registry (src/lib/agents/adapters/) that maps provider IDs to concrete CLI-backed adapters: claude-local, codex-local, cursor-local, gemini-local, opencode-local, pi-local, grok-local, and copilot-local. Documents the AgentAdapter interface, per-adapter stream parsers, the plugin-loader for third-party adapters, provider-registry resolution, and how per-run provider/model overrides are applied at launch time.
- 04. [Agent Runtime — Conversations, Skills & Scheduled Jobs](https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/04-agent-runtime-conversations-skills-scheduled-jobs.md) - End-to-end lifecycle of an agent run: persona templating (persona-manager.ts), conversation creation and transcript storage (conversation-store.ts, conversation-runner.ts), skill loading and scoping (skills/loader.ts, skills/scope.ts), heartbeat polling (heartbeat.ts), action parsing and dispatching (action-parser.ts, action-dispatcher.ts), and the cron-based job scheduler (jobs/job-manager.ts, job-normalization.ts). Includes the agent library of 20 pre-built templates.
- 05. [File-Based Storage & Git-Backed History](https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/05-file-based-storage-git-backed-history.md) - How Cabinet stores all knowledge as markdown files on disk with no traditional database: path conventions (path-utils.ts), page I/O with gray-matter front-matter (page-io.ts), the virtual file tree builder (tree-builder.ts), low-level filesystem operations (fs-operations.ts), and the simple-git integration that auto-commits every save and powers the full diff viewer (git-service.ts). Covers cabinet discovery, multi-cabinet support, and the data-dir layout.
- 06. [Next.js API Routes, Zustand Stores & UI Architecture](https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/06-next.js-api-routes-zustand-stores-ui-architecture.md) - The complete surface of Next.js API routes under src/app/api/ (agents, jobs, kb, git, search, terminal, onboarding, registry, and more), the Zustand client state stores (app-store, tree-store, ai-panel-store, editor-store, search-store), and the major React component groups (editor with Tiptap, sidebar, agents panel, composer, skills browser, settings). Closes the reference with extension points: adding a new provider adapter, registering a skill source, and what to inspect next.

## Source File Index

- `cabinetai/README.md`
- `next.config.ts`
- `package.json`
- `README.md`
- `server/cabinet-daemon.ts`
- `server/db.ts`
- `server/pty/claude-lifecycle.ts`
- `server/pty/manager.ts`
- `server/pty/types.ts`
- `server/search/index-builder.ts`
- `server/search/search-service.ts`
- `src/app/api/agents`
- `src/app/api/jobs`
- `src/app/api/kb`
- `src/components/agents`
- `src/components/editor`
- `src/lib/agents/action-dispatcher.ts`
- `src/lib/agents/adapters/claude-local.ts`
- `src/lib/agents/adapters/codex-local.ts`
- `src/lib/agents/adapters/index.ts`
- `src/lib/agents/adapters/plugin-loader.ts`
- `src/lib/agents/adapters/types.ts`
- `src/lib/agents/conversation-runner.ts`
- `src/lib/agents/conversation-store.ts`
- `src/lib/agents/heartbeat.ts`
- `src/lib/agents/persona-manager.ts`
- `src/lib/agents/provider-registry.ts`
- `src/lib/agents/provider-runtime.ts`
- `src/lib/agents/providers/claude-code.ts`
- `src/lib/agents/skills/loader.ts`
- `src/lib/agents/skills/scope.ts`
- `src/lib/cabinets/discovery.ts`
- `src/lib/git/git-service.ts`
- `src/lib/jobs/job-manager.ts`
- `src/lib/runtime/runtime-config.ts`
- `src/lib/storage/fs-operations.ts`
- `src/lib/storage/page-io.ts`
- `src/lib/storage/path-utils.ts`
- `src/lib/storage/tree-builder.ts`
- `src/stores/ai-panel-store.ts`
- `src/stores/app-store.ts`
- `src/stores/tree-store.ts`

---

## 01. Technical Orientation

> What Cabinet is, its two-process runtime (Next.js on port 4000 + daemon on port 4100), the file-on-disk data model, the npx CLI entry points (cabinetai / create-cabinet), the full tech stack (Next.js 16, TypeScript, Tiptap, Zustand, xterm.js, node-cron, better-sqlite3, ws), and how the rest of this reference is organized.

- Page Markdown: https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/01-technical-orientation.md
- Generated: 2026-05-27T06:27:28.172Z

### Source Files

- `README.md`
- `package.json`
- `next.config.ts`
- `server/cabinet-daemon.ts`
- `src/lib/runtime/runtime-config.ts`
- `cabinetai/README.md`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [README.md](README.md)
- [package.json](package.json)
- [next.config.ts](next.config.ts)
- [server/cabinet-daemon.ts](server/cabinet-daemon.ts)
- [server/db.ts](server/db.ts)
- [src/lib/runtime/runtime-config.ts](src/lib/runtime/runtime-config.ts)
- [src/lib/storage/path-utils.ts](src/lib/storage/path-utils.ts)
- [src/lib/agents/adapters/registry.ts](src/lib/agents/adapters/registry.ts)
- [cabinetai/src/index.ts](cabinetai/src/index.ts)
- [cabinetai/package.json](cabinetai/package.json)
- [cabinetai/README.md](cabinetai/README.md)
- [cli/README.md](cli/README.md)
</details>

# Technical Orientation

Cabinet is a self-hosted, AI-first knowledge base and startup operating system. All content lives as plain Markdown files on disk — no cloud database, no vendor lock-in. An onboarding wizard builds a custom AI team in five questions; from that point agents write documents, file reports, scout feeds, and schedule recurring work while the owner interacts through a rich browser UI backed entirely by the local file system.

This page explains what Cabinet is, how its two-process runtime fits together, how the data model is laid out on disk, the CLI entry points that users interact with, and the full technology stack. It also acts as a navigation aid for the rest of this reference.

---

## What Cabinet Is

Cabinet combines three things that have historically been separate products:

| Capability | How Cabinet delivers it |
|---|---|
| Knowledge base / wiki | WYSIWYG Markdown editor with git-backed history |
| AI agent orchestration | Per-agent personas, scheduled cron jobs, live task transcripts |
| Developer workspace | Embedded web terminal, HTML app hosting, file-based everything |

All state is Markdown on disk. There is no remote service requirement beyond the AI CLI tools you bring (Claude Code, Codex, Gemini, etc.). The git history is Cabinet's undo stack and audit log.

Sources: [README.md](README.md) (philosophy section), [cabinetai/README.md](cabinetai/README.md)

---

## Two-Process Runtime

Cabinet runs as two cooperating Node.js processes:

```text
┌─────────────────────────────────────┐   ┌──────────────────────────────────────┐
│         Next.js App Server          │   │         Cabinet Daemon                │
│         port 4000 (default)         │   │         port 4100 (default)           │
│                                     │   │                                       │
│  • React UI (pages, editor, agents) │   │  • PTY sessions (xterm.js backend)    │
│  • Next.js API routes (/api/*)      │   │  • Structured adapter execution        │
│  • SSR / RSC                        │◄──►  • node-cron job scheduler             │
│  • Reads/writes DATA_DIR files      │   │  • WebSocket event bus                 │
│                                     │   │  • better-sqlite3 DB (WAL mode)        │
│  CABINET_APP_PORT env override      │   │  • Full-text search index             │
└─────────────────────────────────────┘   │  • chokidar file watcher               │
                                          │                                        │
                                          │  CABINET_DAEMON_PORT env override      │
                                          └──────────────────────────────────────┘
```

Starting both together is one command:

```bash
npm run dev:all        # development
npm run start          # production  (next start + tsx server/cabinet-daemon.ts)
```

Sources: [README.md](README.md) (Commands section), [src/lib/runtime/runtime-config.ts:88-107](src/lib/runtime/runtime-config.ts)

### Port resolution

Both ports have a three-tier resolution order, implemented in `runtime-config.ts`:

1. Environment variable (`CABINET_APP_PORT` / `CABINET_DAEMON_PORT`)
2. Value persisted in `DATA_DIR/.cabinet-state/runtime-ports.json`
3. Hard-coded default (4000 / 4100)

```ts
// src/lib/runtime/runtime-config.ts
export function getAppPort(): number {
  const runtimePort = readRuntimePorts().app?.port;
  return parsePort(
    process.env.CABINET_APP_PORT || process.env.PORT,
    typeof runtimePort === "number" && Number.isFinite(runtimePort)
      ? runtimePort : 4000
  );
}
```

Sources: [src/lib/runtime/runtime-config.ts:88-107](src/lib/runtime/runtime-config.ts)

### Daemon responsibilities in detail

The daemon (`server/cabinet-daemon.ts`) is the unified background server. On startup it:

1. Checks that `better-sqlite3` was compiled against the running Node ABI (`ensureBetterSqlite3`).
2. Opens the SQLite database at `DATA_DIR/.cabinet.db` (WAL mode, foreign-keys on).
3. Initializes the full-text search index by walking `DATA_DIR`.
4. Starts a `chokidar` watcher for incremental index updates.
5. Loads all `.agents/*/persona.md` and `.jobs/*.yaml` files to schedule cron jobs via `node-cron`.
6. Cleans up any conversations still marked `running` from a crashed previous session.
7. Listens for WebSocket connections on `/pty` (PTY sessions) and `/events` (browser event bus).
8. Exposes an HTTP API for session output polling, health checks, and detached session creation.

Sources: [server/cabinet-daemon.ts:1-80](server/cabinet-daemon.ts), [server/db.ts:1-50](server/db.ts)

#### Session types

The daemon manages two concrete session kinds in a shared `sessions: Map<string, ActiveSession>`:

| Kind | Type tag | Execution engine | When used |
|---|---|---|---|
| `PtySession` | `"pty"` | `legacy_pty_cli` — spawns a PTY via `node-pty` | Interactive terminal, legacy Claude/Codex PTY flow |
| `StructuredSession` | `"structured"` | Adapter's `execute()` function (subprocess, no raw TTY) | `claude_local`, `codex_local`, and all modern adapters |

Sources: [server/cabinet-daemon.ts:280-310](server/cabinet-daemon.ts)

---

## File-on-Disk Data Model

Cabinet has no application database for user content. Everything is plain files:

```text
~/my-startup/                    ← your cabinet (set via CABINET_DATA_DIR or .cabinet-install.json)
  .cabinet                       ← YAML manifest that marks this as a cabinet root
  .cabinet-state/
    runtime-ports.json           ← persisted port assignments
    install.json                 ← installed app version metadata
    update-status.json           ← update checker state
  .cabinet.db                    ← SQLite (conversations, search metadata, telemetry queue)
  .agents/
    ceo/
      persona.md                 ← frontmatter: active, heartbeat cron, skills, ...
      tasks/                     ← per-task conversation files
  .jobs/
    *.yaml                       ← cron job definitions (schedule, prompt, ownerAgent, ...)
  index.md                       ← entry page (Markdown / YAML frontmatter)
  [your pages and folders]/
    *.md
    index.html                   ← optional embedded HTML app (rendered as iframe)
```

`DATA_DIR` is resolved once at startup by `getManagedDataDir()`:

1. `CABINET_DATA_DIR` env var
2. `.cabinet-install.json` → `dataDir` field in the project root
3. Electron default (`~/Documents/Cabinet` on macOS/Windows, `~/Cabinet` on Linux)
4. `<project-root>/data` (source/dev mode)

Sources: [src/lib/storage/path-utils.ts](src/lib/storage/path-utils.ts), [src/lib/runtime/runtime-config.ts:55-82](src/lib/runtime/runtime-config.ts), [cabinetai/README.md](cabinetai/README.md)

### SQLite layer

SQLite is used exclusively for structured metadata that is impractical in flat files: conversation transcripts, full-text search records, telemetry queue, and token-usage summaries. The database file lives at `DATA_DIR/.cabinet.db` and is opened in WAL mode for safe concurrent reads from Next.js API routes and the daemon.

```ts
// server/db.ts
const DB_PATH = path.join(DATA_DIR, ".cabinet.db");
_db.pragma("journal_mode = WAL");
_db.pragma("foreign_keys = ON");
```

Sources: [server/db.ts:11-44](server/db.ts)

---

## CLI Entry Points

Cabinet ships two npm packages that users interact with via `npx`:

### `cabinetai` (primary CLI)

Published as the `cabinetai` npm package. The `bin` entry point is `dist/index.js` (esbuild bundle). Built with `commander`:

```ts
// cabinetai/src/index.ts
const program = new Command();
program.name("cabinetai").description("Cabinet CLI — AI-first self-hosted knowledge base");
registerCreate(program);   registerRun(program);      registerDoctor(program);
registerUpdate(program);   registerImport(program);   registerList(program);
registerUninstall(program);registerResetConfig(program);
```

| Command | What it does |
|---|---|
| `create [name]` | Scaffold a cabinet directory |
| `run` | Download app if needed, start both servers, open browser |
| `import <template>` | Pull a pre-made cabinet from the GitHub template registry |
| `list` | List all cabinets in the current directory |
| `doctor [--fix]` | Health checks: Node version, cabinet structure, port availability |
| `update` | Pull a newer app version from GitHub releases |
| `uninstall [--all] [--yes]` | Remove cached app versions (and optionally telemetry data) |
| `reset-config` | Reset the local install config |

On first `run`, the CLI downloads the versioned Next.js app into `~/.cabinet/app/v{version}/` and installs its dependencies there. The cabinet directory itself is just content files.

Sources: [cabinetai/src/index.ts](cabinetai/src/index.ts), [cabinetai/package.json](cabinetai/package.json), [cabinetai/README.md](cabinetai/README.md)

### `create-cabinet` (thin wrapper)

Published separately as the `create-cabinet` npm package (`cli/` directory). Exists solely to match the `npx create-*` convention. It delegates immediately to `cabinetai create + run`:

```bash
npx create-cabinet@latest my-startup
# equivalent to: npx cabinetai create my-startup && cd my-startup && npx cabinetai run
```

Sources: [cli/README.md](cli/README.md)

---

## Technology Stack

| Layer | Technology | Version / Notes |
|---|---|---|
| Web framework | Next.js | 16.2.1 — standalone output, `serverExternalPackages` for native modules |
| Language | TypeScript | 5.x throughout (app + daemon + CLI) |
| UI library | React | 19.2.4 |
| Styling | Tailwind CSS v4 + shadcn/ui | |
| Rich-text editor | Tiptap | 3.x — full extension suite (tables, code blocks, math, mentions, YouTube, task lists) |
| Client state | Zustand | 5.x |
| Web terminal | xterm.js (`@xterm/xterm`) | 6.x with fit, unicode11, and web-links addons |
| PTY (server-side) | `node-pty` | 1.1.x — listed in `serverExternalPackages` |
| Job scheduler | `node-cron` | 4.x |
| Database | `better-sqlite3` | 12.x — WAL mode, rebuilt on ABI mismatch at daemon boot |
| WebSockets | `ws` | 8.x — PTY relay + event bus |
| File watcher | `chokidar` | 5.x (no FSEvents; per-directory `fs.watch`) |
| Git integration | `simple-git` | 3.x — auto-commit on save, diff viewer, restore |
| Search | `flexsearch` | 0.8.x — in-process full-text index |
| Markdown parse | `remark` + `unified` + `gray-matter` | Frontmatter + GFM |
| Diagrams | `mermaid` | 11.x |
| i18n | `i18next` + `react-i18next` | Browser language detection |
| Electron (optional) | Electron Forge | 36.x — desktop packaging path |

Sources: [package.json](package.json), [next.config.ts](next.config.ts)

### Provider / adapter layer

Cabinet is BYOAI ("Bring Your Own AI"). An `AgentAdapterRegistry` maps adapter type strings to execution adapters. At startup the registry registers both **structured** adapters (subprocess-based, no raw PTY) and **legacy PTY** adapters for backward compatibility:

| Adapter type | Engine | Provider |
|---|---|---|
| `claude_local` | structured | Claude Code CLI |
| `codex_local` | structured | OpenAI Codex CLI |
| `gemini_local` | structured | Gemini CLI |
| `cursor_local` | structured | Cursor |
| `opencode_local` | structured | OpenCode |
| `pi_local` | structured | Pi |
| `grok_local` | structured | Grok CLI |
| `copilot_local` | structured | GitHub Copilot CLI |
| `claude_code_legacy` | `legacy_pty_cli` | Claude Code (PTY) |
| `codex_cli_legacy` | `legacy_pty_cli` | Codex CLI (PTY) |

Per-run overrides can select provider, model, and reasoning effort without touching the agent persona. External adapters can be registered at runtime via `agentAdapterRegistry.registerExternal()`.

Sources: [src/lib/agents/adapters/registry.ts:1-200](src/lib/agents/adapters/registry.ts)

---

## Repository Layout

```text
cabinet/
  cabinetai/           npx cabinetai CLI package (esbuild, commander)
  cli/                 npx create-cabinet thin wrapper
  server/
    cabinet-daemon.ts  Unified daemon: HTTP + WS + PTY + cron + search
    db.ts              SQLite singleton + migration runner
    migrations/        SQL migration files
    pty/               PTY manager, Claude lifecycle, ANSI helpers
    search/            Index builder, search service, file watcher
  src/
    app/               Next.js App Router pages and API routes
    app/api/           REST API routes called by both the UI and daemon
    components/        React components (editor, sidebar, agents, terminal, ...)
    stores/            Zustand state stores
    lib/
      agents/          Personas, conversation runner, adapter registry, skills
      runtime/         runtime-config.ts, cabinet-env loader
      storage/         path-utils, file I/O helpers
      git/             simple-git wrappers
      jobs/            Job normalization and scheduling helpers
      system/          SQLite preflight, file/SQL migration runners
      telemetry/       Anonymous telemetry (opt-out via env or Settings UI)
  data/                Default cabinet created for dev (not committed)
  docs/                CABINETAI.md CLI reference, other guides
  test/                Unit tests (tsx --test)
  scripts/             Dev helpers, release manifest generator, i18n tools
```

Sources: [README.md](README.md) (Architecture section), directory inspection

---

## How the Rest of This Reference Is Organized

The wiki is structured around the major subsystems of the runtime:

- **Data Model** — deeper dive into the `.cabinet` manifest, agent persona frontmatter, and job YAML schema.
- **Daemon Internals** — PTY lifecycle, structured adapter execution, cron scheduling, and WebSocket event bus channels.
- **Editor and UI** — Tiptap extension set, slash commands, embedded HTML apps, and xterm.js terminal integration.
- **Agent System** — how personas, skills, heartbeats, missions, and the conversation store fit together.
- **CLI Reference** — full flag documentation for all `cabinetai` commands (see also `docs/CABINETAI.md`).
- **Configuration** — environment variables, `.cabinet.env`, multi-cabinet setups, and Electron packaging.
- **Contributing** — dev setup, linting, test runner, release flow, and the update system.

---

## Summary

Cabinet's design is intentionally simple at the boundaries: two processes, one of which is stock Next.js, files on disk as the source of truth, and a pluggable adapter layer so any local AI CLI can do the work. The daemon owns all long-running concerns — PTY processes, WebSocket relay, cron jobs, the SQLite database, and the search index — while the Next.js app owns the UI and the REST API surface that the browser calls. The two communicate over HTTP and the daemon's WebSocket event bus. Everything a user produces is a plain file they can inspect, version-control, and take with them.

Sources: [server/cabinet-daemon.ts:1-25](server/cabinet-daemon.ts), [src/lib/runtime/runtime-config.ts](src/lib/runtime/runtime-config.ts)

---

## 02. Cabinet Daemon — WebSocket Bus, PTY Sessions & Job Scheduler

> The unified background server (server/cabinet-daemon.ts) that combines the WebSocket event bus, node-cron job scheduler, PTY/xterm terminal sessions with Claude lifecycle management, full-text search indexing, SQLite initialization, and file-watcher-driven real-time updates. Covers the daemon HTTP + WS server boot sequence, message protocol, and how each subsystem registers with the single server instance.

- Page Markdown: https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/02-cabinet-daemon-websocket-bus-pty-sessions-job-scheduler.md
- Generated: 2026-05-27T06:27:55.044Z

### Source Files

- `server/cabinet-daemon.ts`
- `server/db.ts`
- `server/pty/manager.ts`
- `server/pty/claude-lifecycle.ts`
- `server/pty/types.ts`
- `server/search/index-builder.ts`
- `server/search/search-service.ts`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [server/cabinet-daemon.ts](server/cabinet-daemon.ts)
- [server/db.ts](server/db.ts)
- [server/pty/manager.ts](server/pty/manager.ts)
- [server/pty/claude-lifecycle.ts](server/pty/claude-lifecycle.ts)
- [server/pty/types.ts](server/pty/types.ts)
- [server/search/index-builder.ts](server/search/index-builder.ts)
- [server/search/search-service.ts](server/search/search-service.ts)
- [server/search/watcher.ts](server/search/watcher.ts)
- [server/search/types.ts](server/search/types.ts)
</details>

# Cabinet Daemon — WebSocket Bus, PTY Sessions & Job Scheduler

The Cabinet Daemon (`server/cabinet-daemon.ts`) is the unified background server process for Cabinet. It combines every subsystem that must run continuously but outside the Next.js request cycle: a node-pty terminal multiplexer for AI agent sessions, a `node-cron` job scheduler for agent jobs and heartbeats, a WebSocket event bus for real-time frontend updates, an in-process FlexSearch full-text index for Markdown notes, and the SQLite database initialization layer. All subsystems share one `http.Server` instance and are wired together at startup.

Understanding the daemon is essential for tracing how a scheduled job fires, how a Claude terminal session is spawned and auto-completed, how the frontend receives live search-index updates, and how conversation state is finalized after a process exits.

---

## Daemon Boot Sequence

The daemon follows a strict sequential initialization before the HTTP server starts listening, and launches several async subsystems immediately after `server.listen` completes.

```text
1. ensureBetterSqlite3()        — native-binary preflight (rebuild if ABI mismatch)
2. loadCabinetEnv()             — merge .cabinet.env into process.env
3. getDb()                      — open SQLite, run file + SQL migrations
4. http.createServer(...)       — create HTTP server (handlers attached inline)
5. WebSocketServer (PTY)        — noServer, path: / or /api/daemon/pty
6. WebSocketServer (Events)     — noServer, path: /events or /api/daemon/events
7. server.listen(PORT)  ──────────────────────────────────────┐
   └→ reloadSchedules()         — parse .agents/*/persona.md + .jobs/*.yaml   │
   └→ cleanupStaleRunningConversations()                                        │
   └→ cleanupStaleStagingAttachments()                                          │
   └→ guardAgainstBigTree()                                                     │
       └→ bootstrapSearchIndex()                                                 │
           └→ startSearchWatcher()                                               │
   └→ loadExternalAdapters()                                                    │
   └→ telemetry: app.launched                                                   │
```

Sources: [server/cabinet-daemon.ts:1-25](), [server/cabinet-daemon.ts:1885-1940]()

---

## HTTP Server and API Endpoints

The daemon runs a plain `http.createServer` (no Express). Every route except `/health` requires a daemon bearer token validated by `isDaemonTokenValid`. CORS is applied per-request; only origins listed in `CABINET_APP_ORIGIN` or loopback addresses are allowed.

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/health` | Status: session count, job count, subscriber count |
| `GET` | `/session/:id/output` | Poll output for active or completed session |
| `POST` | `/sessions` | Create a detached session without a WebSocket |
| `POST` | `/session/:id/input` | Write stdin to a live PTY session |
| `POST` | `/session/:id/close` | Graceful close: write `/exit`, 2s SIGTERM fallback |
| `POST` | `/session/:id/stop` | Immediate SIGTERM, SIGKILL fallback after 2 s |
| `GET` | `/sessions` | List all active sessions |
| `POST` | `/reload-schedules` | Force-reload all cron schedules |
| `POST` | `/trigger` | Manually spawn a one-off prompt session |
| `GET` | `/search` | Full-text search: pages, agents, tasks |

Sources: [server/cabinet-daemon.ts:1501-1790]()

### WebSocket Upgrade Routing

Two `WebSocketServer` instances share the same underlying `http.Server` via `noServer` mode. The `upgrade` event on the server dispatches by path:

- `/` or `/api/daemon/pty` → PTY terminal server (`wssPty`)
- `/events` or `/api/daemon/events` → event bus (`wssEvents`)

All upgrades are rejected with HTTP 401 if the token is invalid.

Sources: [server/cabinet-daemon.ts:1800-1840]()

---

## WebSocket Event Bus

The event bus (`/events`) provides a pub/sub broadcast channel for real-time updates from daemon subsystems to the frontend.

### Protocol

Clients connect and receive all channels by default (`channels = new Set(["*"])`). They can narrow subscriptions by sending JSON frames:

```json
// Subscribe to a specific channel
{ "subscribe": "search" }

// Unsubscribe
{ "unsubscribe": "search" }
```

The daemon calls `broadcast(channel, data)` from any subsystem. Each message is serialized as:
```json
{ "channel": "search", "type": "search:ready", "pages": 142, "tookMs": 87 }
```

### Event Channels

| Channel | Message types | Emitted by |
|---------|--------------|-----------|
| `search` | `search:indexing`, `search:ready`, `search:indexed`, `search:error` | `bootstrapSearchIndex`, `startSearchWatcher` |

Sources: [server/cabinet-daemon.ts:610-660](), [server/cabinet-daemon.ts:195-210]()

The `subscribers` array is managed directly:

```ts
// server/cabinet-daemon.ts
const subscribers: EventSubscriber[] = [];

function broadcast(channel: string, data: Record<string, unknown>): void {
  const message = JSON.stringify({ channel, ...data });
  for (const sub of subscribers) {
    if (sub.channels.has(channel) || sub.channels.has("*")) {
      if (sub.ws.readyState === WebSocket.OPEN) sub.ws.send(message);
    }
  }
}
```

---

## PTY Session Architecture

### Session Types

Every active terminal run lives in the `sessions: Map<string, ActiveSession>` map. Two kinds coexist:

```text
BaseSession (server/pty/types.ts)
├── PtySession  (kind: "pty")       — node-pty subprocess + xterm stream
└── StructuredSession (kind: "structured") — adapter.execute() async runner
```

The `PtySession` adds PTY-specific fields: the `node-pty` handle, timers for auto-exit, initial-prompt bookkeeping, `outputMode` (plain vs. `claude-stream-json`), and the `trigger` that controls whether auto-exit fires or the session stays alive awaiting user input.

Sources: [server/pty/types.ts:1-80]()

### Session Routing: `createSession`

The `createSession` function is the single entry point for spawning any session, regardless of whether it arrives from the WebSocket path or the HTTP `/sessions` endpoint:

```text
createSession(input)
    │
    ├── adapterType === "shell"       → ptyManager.spawn() (plain shell)
    ├── adapter.executionEngine !== "legacy_pty_cli"
    │       → createStructuredSession()  (native async adapter)
    └── legacy_pty_cli or no adapter → ptyManager.spawn() (PTY CLI)
```

Sources: [server/cabinet-daemon.ts:498-545]()

### PTY Manager (`server/pty/manager.ts`)

`createPtyManager(deps)` is a factory that receives shared infrastructure as `PtyManagerDeps` (the `sessions` map, `completedOutput` map, callbacks for finalization/output/cleanup) so it can operate without importing the full daemon module.

At spawn time, the manager:
1. Resolves the execution provider id and calls `getSessionLaunchSpec` or `getOneShotLaunchSpec` to obtain the `command`, `args`, and `readyStrategy`.
2. Merges `.cabinet.env` values at spawn time (mtime-cached, no daemon restart needed).
3. Calls `node-pty`'s `pty.spawn` with `xterm-256color`, 120×30 cols/rows, and an enriched `PATH` that includes `~/.local/bin`, `/opt/homebrew/bin`, and NVM node bins.
4. For one-shot Claude sessions, rewrites args to add `--output-format stream-json --include-partial-messages`.
5. For non-shell sessions, sets `ZDOTDIR=/dev/null` and clears `BASH_ENV` to suppress dotfile noise from sub-shells the CLI may spawn.

Sources: [server/pty/manager.ts:90-200]()

### Structured Sessions

When an adapter's `executionEngine` is not `"legacy_pty_cli"`, the daemon calls `adapter.execute()` directly — no PTY. The structured session:

- Captures `pid` + `processGroupId` from the `onSpawn` callback.
- Collects `stdout` via `onLog` into `session.output[]`; `stderr` is buffered separately for error classification and never shown to the user.
- On completion, extracts `adapterSessionId`, `adapterSessionParams`, `adapterUsage`, and runs `adapter.classifyError` to produce a typed `adapterErrorKind` (`cli_not_found`, `auth_expired`, `rate_limited`, etc.).
- Persists a resume handle via `writeSession` on successful exit so future turns can resume the same adapter session.

Sources: [server/cabinet-daemon.ts:300-490]()

### Reconnect and Replay

When a WebSocket client connects with `reconnect=1`, the daemon never spawns a new process. Instead it:
1. Looks up the in-memory `completedOutput` cache (recent runs).
2. Falls back to `readConversationTranscript` from disk for older sessions.
3. Emits a provenance banner (provider, adapter, timestamps) then the replay, then closes the socket.

Sources: [server/cabinet-daemon.ts:740-820]()

---

## Claude Lifecycle Management (`server/pty/claude-lifecycle.ts`)

Claude Code runs interactively as a persistent TUI. The lifecycle module manages the handshake between Cabinet and the Claude CLI for both one-shot (job/heartbeat) and manual sessions.

### Session State Machine

```mermaid
stateDiagram-v2
    [*] --> Spawned : ptyManager.spawn()
    Spawned --> WaitingForReady : await claudePromptReady()
    WaitingForReady --> PromptSubmitted : submitInitialPrompt()
    PromptSubmitted --> Running : output streaming
    Running --> AwaitingInput : trigger=manual AND\ntranscriptShowsCompletedRun\n[1.2s idle grace]
    Running --> Completing : trigger≠manual AND\ntranscriptShowsCompletedRun\n[1.2s grace timer]
    AwaitingInput --> Running : new output > 120 bytes\n[2s busy grace]
    Completing --> Exited : completeClaudeSession()\n→ write /exit → kill fallback
    Exited --> [*] : finalizeSessionConversation()
```

### Key Functions

| Function | Responsibility |
|----------|---------------|
| `claudePromptReady(output)` | Detects Claude's input cursor via `shift+tab to cycle` footer or bare `❯`/`>` glyph |
| `submitInitialPrompt(session)` | Writes prompt to PTY, then sends `\r` after 600ms to clear Claude's paste-grouping window |
| `consumeStructuredOutput(session, chunk)` | Passes chunks through `ClaudeStreamAccumulator` when `outputMode === "claude-stream-json"` |
| `maybeAutoExitClaudeSession(session, deps)` | After each chunk, checks `transcriptShowsCompletedRun`; schedules 1.2s completion timer or idle-flip depending on `trigger` |
| `completeClaudeSession(session, output, deps)` | Sets `resolvedStatus = "completed"`, calls `finalizeConversation`, writes `/exit\r`, sets 1.5s kill fallback |
| `scheduleStreamCabinetExtraction(session)` | Debounced (1s) extraction of fenced ` ```cabinet ` blocks for live `meta.summary` and `meta.artifactPaths` updates |
| `distillPtyOutput(plain, exitCode, providerId)` | Generates a synthetic one-liner summary from PTY output (avoids feeding TUI chrome to `parseCabinetBlock`) |

Sources: [server/pty/claude-lifecycle.ts:1-50](), [server/pty/claude-lifecycle.ts:90-190](), [server/pty/claude-lifecycle.ts:200-280]()

### Manual vs. Scheduled Trigger Behavior

The `trigger` field from `ConversationMeta` is threaded through to each `PtySession`. It changes the behavior at idle detection:

- **`trigger === "manual"`**: On idle, flip `meta.awaitingInput = true` and publish `task.updated`; keep the PTY alive so the user can continue typing in the xterm. When new output arrives (> 120 bytes over 2 s), flip back to "running".
- **Any other trigger** (`job`, `heartbeat`, `agent`): Start the 1.2 s grace timer and auto-exit. This prevents scheduled job runs from accumulating unclosed PTY processes.

Sources: [server/pty/claude-lifecycle.ts:240-310]()

---

## Job Scheduler

The scheduler uses `node-cron` to fire agent jobs and persona heartbeats at configured intervals. It discovers schedule configuration from the filesystem on boot and live-reloads on changes.

### Schedule Sources

| Config file | Parsed for |
|------------|-----------|
| `.agents/<slug>/persona.md` frontmatter | `heartbeat` (cron expr), `active`, `heartbeatEnabled` |
| `.jobs/<name>.yaml` | `id`, `schedule`, `enabled`, `ownerAgent`, `prompt`, `oneShot`, `timeout` |

Cabinet supports multiple "cabinet" data directories (rooms); `discoverAllCabinets()` enumerates all of them and each gets its own job/heartbeat namespace.

### Schedule Key Format

```
{cabinetPath}::job::{agentSlug}/{jobId}
{cabinetPath}::heartbeat::{agentSlug}
```

This ensures that jobs in different rooms never collide even if they share the same slug.

### Job Firing

When a cron task fires, `scheduleJob` calls the Next.js app API via `fetch`:

```ts
// server/cabinet-daemon.ts
void putJson(`${getAppOrigin()}/api/agents/${job.agentSlug}/jobs/${job.id}`, {
  action: "run",
  source: "scheduler",
  cabinetPath: job.cabinetPath,
  scheduledAt,
})
```

One-shot jobs are disabled via a follow-up `PUT` with `{ action: "update", enabled: false }` and removed from `scheduledJobs`.

Sources: [server/cabinet-daemon.ts:670-760]()

### Live Reload

A `chokidar` watcher monitors:
- `DATA_DIR/**/.agents/*/persona.md`
- `DATA_DIR/**/.jobs/*.yaml`
- `DATA_DIR/**/.agents/*/jobs/*.yaml`
- `DATA_DIR/**/.cabinet`

Any `add`, `change`, or `unlink` event queues a 200ms debounced `reloadSchedules()`. If the watcher itself fails with `EMFILE` or `ENOSPC`, it is closed gracefully and manual `POST /reload-schedules` remains available.

Sources: [server/cabinet-daemon.ts:1845-1880]()

---

## Full-Text Search Subsystem

### Index Architecture

The search index is built on [FlexSearch](https://github.com/nextapps-de/flexsearch) `Document` with `tokenize: "forward"` (prefix matching). Four fields are indexed per document:

| Field | Weight |
|-------|--------|
| `title` | 100 |
| `headings` | 50 |
| `tags` | 30 |
| `body` | 10 |

The `SearchIndex` class (`server/search/index-builder.ts`) wraps the FlexSearch document with a `Map<string, IndexedPageRecord>` side-store that preserves structured metadata (tagList, icon, modified, per-line content arrays) for snippet extraction.

Sources: [server/search/index-builder.ts:1-80]()

### Bootstrap Process

```ts
// server/cabinet-daemon.ts (startup sequence)
void guardAgainstBigTree().then(() =>
  bootstrapSearchIndex().then(() => startSearchWatcher())
);
```

`bootstrapSearchIndex` calls `walkDataDir()` to enumerate all `.md` files (following symlinked cabinet roots, skipping hidden directories and `CLAUDE.md`), then `buildPageRecord(fsPath, virtualPath)` for each file. Virtual paths strip the `DATA_DIR` prefix and `.md` extension; `index.md` files collapse to their parent directory path.

During indexing the daemon broadcasts `search:indexing` to all event-bus subscribers. On completion it broadcasts `search:ready` with `{ pages, tookMs }`.

Sources: [server/cabinet-daemon.ts:193-235](), [server/search/index-builder.ts:115-185]()

### Big-Tree Guard

Before bootstrapping the index, the daemon runs a preflight directory count. If the DATA_DIR contains more than 1,500 watchable directories **and** `CABINET_ALLOW_BIG_TREE` is not set, the daemon prints a diagnostic and calls `process.exit(1)`. This prevents exhausting OS file descriptor limits (`EMFILE`) that would crash the watcher silently.

Sources: [server/cabinet-daemon.ts:115-175]()

### Live Watcher

`startWatcher(searchIndex, opts)` (`server/search/watcher.ts`) opens a chokidar watcher on `DATA_DIR` for `*.md` file events. Each event is debounced by 150ms; then:

- `add` → `index.add(record)`
- `change` → `index.update(record)`
- `unlink` → `index.remove(virtualPath)`

Each successful update triggers the `onIndexed` callback, which the daemon uses to broadcast `search:indexed` on the event bus.

On `EMFILE`/`ENOSPC`, the watcher shuts itself down gracefully (logs once, no crash). Search queries still return results; live updates simply stop.

Sources: [server/search/watcher.ts:1-100]()

### Search Endpoint

`GET /search?q=...&scope=all|pages|agents|tasks&limit=50&cabinet=path` calls `runSearch(sources, query, scope, limit, cabinet)`. The response shape:

```ts
interface SearchResponse {
  query: string;
  scope: SearchScope;
  pages: PageHit[];    // up to 50 by default
  agents: AgentHit[];  // up to 20
  tasks: TaskHit[];    // up to 20
  tookMs: number;
  indexReady: boolean;
}
```

Agents and tasks are loaded on demand from filesystem/DB via `loadAgentDocs()` / `loadTaskDocs()`. Room scoping restricts results to paths within the requested `cabinet` prefix.

Sources: [server/cabinet-daemon.ts:1750-1795](), [server/search/search-service.ts:95-200]()

---

## SQLite Initialization (`server/db.ts`)

`getDb()` is a lazy singleton. On first call it:

1. Ensures `DATA_DIR` exists.
2. Runs `runFileMigrationsSync()` for filesystem-level migrations (renames, moves).
3. Opens `DATA_DIR/.cabinet.db` with `better-sqlite3`.
4. Sets `PRAGMA journal_mode = WAL` and `PRAGMA foreign_keys = ON`.
5. Runs numbered SQL migrations from `server/migrations/NNN_description.sql`.

`closeDb()` is called by the `shutdown` handler on SIGINT/SIGTERM.

Sources: [server/db.ts:1-60]()

---

## Graceful Shutdown

The `shutdown()` function handles both `SIGINT` and `SIGTERM`:

1. Emits `app.exited` telemetry.
2. Clears the telemetry session id.
3. Stops all `scheduledJobs` and `scheduledHeartbeats` cron tasks.
4. Sends `SIGTERM` to every active session (PTY and structured).
5. Closes the schedule file watcher.
6. Closes the SQLite connection.
7. Closes the HTTP server and calls `process.exit(0)`.

Sources: [server/cabinet-daemon.ts:1955-1985]()

---

## Session Memory Management

Two cleanup intervals run in the background:

| Interval | Purpose |
|----------|---------|
| Every 5 minutes | Evict `completedOutput` entries older than 30 minutes |
| Every 60 seconds | Move exited + detached sessions idle for > 10 minutes to `completedOutput`, then delete from `sessions` |

On daemon start, `cleanupStaleRunningConversations()` scans all cabinets for conversations still marked `"running"` in their `meta.json` (from a previous crashed daemon) and finalizes them as `"failed"`. This prevents permanently stuck spinners in the UI.

Sources: [server/cabinet-daemon.ts:405-430](), [server/cabinet-daemon.ts:855-870]()

---

## Summary

The Cabinet Daemon is a single Node.js process (`npx tsx server/cabinet-daemon.ts`) that owns the runtime surface for every background operation: it is the PTY multiplexer for Claude and other AI CLIs, the cron runner for agent jobs and persona heartbeats, the WebSocket broadcast bus for live frontend updates, the boot owner of the SQLite database, and the search indexer for the Markdown workspace. All subsystems share one `http.Server` and one `sessions` map, with WebSocket routing split between a PTY path and an event-bus path. The Claude lifecycle helpers in `server/pty/claude-lifecycle.ts` implement the nuanced handshake — prompt injection, stream parsing, cabinet-block extraction, and trigger-aware auto-exit — that distinguishes scheduled AI runs from interactive terminal sessions.

---

## 03. Provider Adapter Layer — BYOAI Adapters & Registries

> The pluggable adapter registry (src/lib/agents/adapters/) that maps provider IDs to concrete CLI-backed adapters: claude-local, codex-local, cursor-local, gemini-local, opencode-local, pi-local, grok-local, and copilot-local. Documents the AgentAdapter interface, per-adapter stream parsers, the plugin-loader for third-party adapters, provider-registry resolution, and how per-run provider/model overrides are applied at launch time.

- Page Markdown: https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/03-provider-adapter-layer-byoai-adapters-registries.md
- Generated: 2026-05-27T06:28:14.667Z

### Source Files

- `src/lib/agents/adapters/index.ts`
- `src/lib/agents/adapters/types.ts`
- `src/lib/agents/adapters/claude-local.ts`
- `src/lib/agents/adapters/codex-local.ts`
- `src/lib/agents/adapters/plugin-loader.ts`
- `src/lib/agents/provider-registry.ts`
- `src/lib/agents/provider-runtime.ts`
- `src/lib/agents/providers/claude-code.ts`

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [src/lib/agents/adapters/types.ts](src/lib/agents/adapters/types.ts)
- [src/lib/agents/adapters/registry.ts](src/lib/agents/adapters/registry.ts)
- [src/lib/agents/adapters/index.ts](src/lib/agents/adapters/index.ts)
- [src/lib/agents/adapters/plugin-loader.ts](src/lib/agents/adapters/plugin-loader.ts)
- [src/lib/agents/adapters/claude-local.ts](src/lib/agents/adapters/claude-local.ts)
- [src/lib/agents/adapters/codex-local.ts](src/lib/agents/adapters/codex-local.ts)
- [src/lib/agents/adapters/grok-local.ts](src/lib/agents/adapters/grok-local.ts)
- [src/lib/agents/adapters/copilot-local.ts](src/lib/agents/adapters/copilot-local.ts)
- [src/lib/agents/adapters/pi-local.ts](src/lib/agents/adapters/pi-local.ts)
- [src/lib/agents/adapters/opencode-local.ts](src/lib/agents/adapters/opencode-local.ts)
- [src/lib/agents/adapters/cursor-local.ts](src/lib/agents/adapters/cursor-local.ts)
- [src/lib/agents/adapters/error-classification.ts](src/lib/agents/adapters/error-classification.ts)
- [src/lib/agents/adapters/utils.ts](src/lib/agents/adapters/utils.ts)
- [src/lib/agents/provider-registry.ts](src/lib/agents/provider-registry.ts)
- [src/lib/agents/provider-runtime.ts](src/lib/agents/provider-runtime.ts)
- [src/lib/agents/providers/claude-code.ts](src/lib/agents/providers/claude-code.ts)
</details>

# Provider Adapter Layer — BYOAI Adapters & Registries

Cabinet's provider adapter layer is the seam between the job runner and every supported AI CLI tool. Rather than wiring the runner directly to any one tool, it routes each run through an `AgentExecutionAdapter` whose `type` key is stored with the run. A dedicated registry maps those keys to concrete adapter objects, and a separate plugin loader lets users drop in third-party adapters at runtime without touching source code.

The layer has two complementary registries: a **provider registry** (`src/lib/agents/provider-registry.ts`) that describes what tools are available and how to check their health, and an **adapter registry** (`src/lib/agents/adapters/registry.ts`) that describes how to actually execute a run against those tools. Providers are stable identifiers; adapters are interchangeable execution strategies that reference providers by `providerId`.

---

## Architecture Overview

```text
┌─────────────────────────────────────────────────────────┐
│                   Job Runner / Daemon                   │
│   resolves adapterType ──► agentAdapterRegistry.get()   │
└──────────────────────────┬──────────────────────────────┘
                           │
          ┌────────────────▼────────────────┐
          │       AgentAdapterRegistry       │   registry.ts
          │  register()  registerExternal()  │
          └────────┬─────────────────────────┘
                   │
   ┌───────────────┼───────────────────────────────────────┐
   │               │  built-in adapters                    │
   │  claude_local │ codex_local │ gemini_local │ pi_local  │
   │  cursor_local │ opencode_local │ grok_local            │
   │  copilot_local │ *_legacy (PTY) ×8                     │
   └───────────────┼───────────────────────────────────────┘
                   │ external plugins
          ┌────────▼────────────────┐
          │   plugin-loader.ts      │  ~/.cabinet/adapter-plugins.json
          │ loadExternalAdapters()  │
          └─────────────────────────┘

   ProviderRegistry (provider-registry.ts)
   ├── claude-code  ├── codex-cli   ├── gemini-cli
   ├── cursor-cli   ├── opencode    ├── pi
   ├── grok-cli     └── copilot-cli
```

---

## `AgentExecutionAdapter` Interface

Every adapter — built-in or third-party — must satisfy `AgentExecutionAdapter` defined in `src/lib/agents/adapters/types.ts`.

| Field | Type | Purpose |
|---|---|---|
| `type` | `string` | Stable key stored with the run (e.g. `"claude_local"`) |
| `name` | `string` | Human-readable label shown in the UI |
| `providerId` | `string?` | Links back to the `ProviderRegistry` entry |
| `executionEngine` | `AgentAdapterExecutionEngine` | One of `"structured_cli"`, `"legacy_pty_cli"`, `"api"`, `"http"`, `"process"` |
| `supportsSessionResume` | `boolean?` | Whether the adapter can resume a prior conversation |
| `supportsDetachedRuns` | `boolean?` | Whether the adapter can run without an attached terminal |
| `models` | `AgentAdapterModel[]?` | Provider-backed model list, forwarded to the UI picker |
| `effortLevels` | `AgentAdapterEffortLevel[]?` | Configurable reasoning depth labels |
| `sessionCodec` | `AdapterSessionCodec?` | Serialize / deserialize opaque session state |
| `testEnvironment(ctx?)` | `Promise<AdapterEnvironmentTestResult>` | **Required.** Reports health (pass/warn/fail) + per-check hints |
| `execute(ctx)` | `Promise<AdapterExecutionResult>?` | **Optional** on legacy PTY adapters; required for structured execution |
| `classifyError(stderr, exitCode)` | `ConversationErrorClassification?` | Maps failure output to a canonical error kind for UI remediation |

Sources: [src/lib/agents/adapters/types.ts:85-130]()

### `AdapterExecutionContext`

The runner passes a single context object to `execute()`:

| Field | Notes |
|---|---|
| `runId` | Unique ID of the job, used by Pi adapter for session-file naming |
| `adapterType` | Resolved adapter type string |
| `config` | Opaque `Record<string, unknown>` carrying model, effort, systemPrompt, skillsDir, etc. |
| `prompt` | The full prompt text piped as stdin or passed as a positional arg |
| `cwd` | Working directory for the child process |
| `timeoutMs` | Optional kill deadline |
| `sessionId` / `sessionParams` | Resume handles, codec-deserialized before the call |
| `onLog(stream, chunk)` | Streaming callback for live log display |
| `onMeta(meta)` | Pre-launch metadata: command, args, env |
| `onSpawn(meta)` | Post-fork metadata: PID, process-group ID, started timestamp |

Sources: [src/lib/agents/adapters/types.ts:23-43]()

### `AdapterExecutionResult`

Each successful or failed `execute()` returns:

| Field | Notes |
|---|---|
| `exitCode` / `signal` / `timedOut` | Raw process termination status |
| `usage` | `inputTokens`, `outputTokens`, `cachedInputTokens` when the adapter can parse them |
| `sessionId` / `sessionParams` / `sessionDisplayId` | Codec-ready resume handles |
| `provider` / `model` | Actual provider/model reported by the run |
| `billingType` | `"api"` \| `"subscription"` \| `"metered_api"` \| `"credits"` \| `"unknown"` |
| `summary` / `output` | First non-empty output line and full assistant text |
| `errorMessage` / `errorCode` | On failure, human-readable message + structured code |
| `clearSession` | Signal to the runner to discard the stored session |

Sources: [src/lib/agents/adapters/types.ts:44-70]()

---

## Adapter Registry

`AgentAdapterRegistry` (`registry.ts`) is a singleton that owns the in-process map of adapter type → adapter object.

```typescript
// src/lib/agents/adapters/registry.ts (simplified)
class AgentAdapterRegistry {
  adapters = new Map<string, AgentExecutionAdapter>();
  private builtinFallbacks = new Map<string, AgentExecutionAdapter>();
  defaultAdapterType = claudeLocalAdapter.type; // "claude_local"

  register(adapter)          // built-in, no fallback tracking
  registerExternal(adapter)  // saves the displaced built-in as a fallback
  unregisterExternal(type)   // restores the built-in fallback
  get(type)
  listAll()
  findByProviderId(providerId)
}
export const agentAdapterRegistry = new AgentAdapterRegistry();
```

When `registerExternal` displaces an existing built-in with the same `type`, the old adapter is preserved in `builtinFallbacks`. `unregisterExternal` restores it, making the plugin lifecycle safe even if the same type is re-registered.

Sources: [src/lib/agents/adapters/registry.ts:140-185]()

### Provider-to-Adapter Maps

Two lookup tables in `registry.ts` drive adapter resolution:

```typescript
export const DEFAULT_ADAPTER_BY_PROVIDER_ID: Record<string, string> = {
  "claude-code":  "claude_local",
  "codex-cli":    "codex_local",
  "gemini-cli":   "gemini_local",
  "cursor-cli":   "cursor_local",
  "opencode":     "opencode_local",
  "pi":           "pi_local",
  "grok-cli":     "grok_local",
  "copilot-cli":  "copilot_local",
};

export const LEGACY_ADAPTER_BY_PROVIDER_ID: Record<string, string> = {
  "claude-code":  "claude_code_legacy",
  "codex-cli":    "codex_cli_legacy",
  // … one per provider
};
```

`defaultAdapterTypeForProvider(providerId?)` walks `DEFAULT_ADAPTER_BY_PROVIDER_ID` then falls back to the registry's `defaultAdapterType` (`"claude_local"`).

Sources: [src/lib/agents/adapters/registry.ts:25-46]()

---

## Built-in Adapters

All eight built-in adapters use `executionEngine: "structured_cli"` and delegate subprocess management to `runChildProcess` from `utils.ts`.

### Execution Engine Taxonomy

| Engine | Meaning | Adapters |
|---|---|---|
| `structured_cli` | Spawns CLI binary, parses structured output (JSON stream or plain text) | All `*_local` adapters |
| `legacy_pty_cli` | PTY terminal session; no `execute()` method; `experimental: true` | All `*_legacy` adapters |

### Adapter-by-Adapter Summary

| Adapter type | CLI command | Session resume | Stream format | Billing type |
|---|---|---|---|---|
| `claude_local` | `claude` | `--resume <sessionId>` (ID-based) | `--output-format stream-json` | `subscription` |
| `codex_local` | `codex` | `threadId` emitted on stdout | `--json` event stream | `unknown` |
| `gemini_local` | `gemini` | via session ID | JSON stream | `unknown` |
| `cursor_local` | `cursor` | `--resume <sessionId>` | `--output-format stream-json` | `subscription` |
| `opencode_local` | `opencode` | `--session <sessionId>` | `run --format json` | `unknown` |
| `pi_local` | `pi` | `--session <file>` (file-based) | `--mode json` | `unknown` |
| `grok_local` | `grok` | none | plain stdout | `api` |
| `copilot_local` | `copilot` | none | plain stdout | `subscription` |

Sources: [src/lib/agents/adapters/registry.ts:183-200](), [src/lib/agents/adapters/grok-local.ts:44-52](), [src/lib/agents/adapters/copilot-local.ts:44-52]()

### `claude_local` — Structured Streaming Adapter

The Claude adapter is the most feature-complete. Its `buildClaudeArgs` function assembles the CLI invocation:

```typescript
// src/lib/agents/adapters/claude-local.ts (condensed)
const args = [
  "-p", "--output-format", "stream-json",
  "--include-partial-messages", "--verbose",
  "--dangerously-skip-permissions",
];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (model)          args.push("--model", model);
if (effort)         args.push("--effort", effort);
if (systemPrompt)   args.push("--system-prompt", systemPrompt);
if (skillsDir) {
  args.push("--plugin-dir", skillsDir);
  args.push("--add-dir", skillsDir);
}
```

Skills are injected via `--plugin-dir` (to register them as slash-commands) and `--add-dir` (for read access). The session codec stores `{ resumeId: string }` and displays as `Claude · <8-char prefix>`.

Sources: [src/lib/agents/adapters/claude-local.ts:37-80]()

### `codex_local` — JSON Event Stream Adapter

Codex emits model-rejection errors as `{"type":"error",...}` events on stdout, not stderr. The adapter includes a provider-specific classifier that runs before the generic chain:

```typescript
// src/lib/agents/adapters/codex-local.ts
function classifyCodexModelUnavailable(stderr, exitCode) {
  // Matches: "not supported when using codex with a chatgpt account"
  // Returns kind: "model_unavailable" so the UI surfaces the right hint
}
```

The codex session codec uses a `threadId` field, and its args always include `--ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox`.

Sources: [src/lib/agents/adapters/codex-local.ts:26-68](), [src/lib/agents/adapters/codex-local.ts:74-103]()

### `pi_local` — File-Based Session Adapter

Pi's session state is a JSON file on disk rather than an ID string:

- Session files live in `~/.cabinet/pi-sessions/<runId>.json`.
- `ensureSessionFile(runId, stored)` creates the file on first use or reuses an existing path.
- The codec serializes/deserializes `{ sessionFile: string }` and uses the filename (minus extension) as the display ID.
- The model argument accepts `"provider/model"` notation, which `splitProviderModel` decomposes into separate `--provider` and `--model` flags.

Sources: [src/lib/agents/adapters/pi-local.ts:36-60](), [src/lib/agents/adapters/pi-local.ts:62-90]()

### `grok_local` / `copilot_local` — Plain Text Adapters

These two adapters do not have dedicated stream parsers; they treat the entire stdout as the assistant response. Both forward raw chunks directly to `ctx.onLog`, accumulate into `forwardedStdout`, and return the trimmed total as `output`. Neither supports session resume. Copilot passes `--allow-all-tools` to prevent headless runs from stalling on approval prompts.

Sources: [src/lib/agents/adapters/grok-local.ts:36-75](), [src/lib/agents/adapters/copilot-local.ts:33-68]()

---

## Per-Adapter Stream Parsers

Adapters with structured output use dedicated stream accumulator modules:

| Module | Adapter | Parser role |
|---|---|---|
| `claude-stream.ts` | `claude_local` | `stream-json` NDJSON → assistant text, session ID, model, usage, billing type |
| `codex-stream.ts` | `codex_local` | JSON event stream → display text, error events, thread ID, usage |
| `cursor-stream.ts` | `cursor_local` | `stream-json` NDJSON → text, session ID; detects unknown-session errors |
| `opencode-stream.ts` | `opencode_local` | JSON events → text, session ID, usage; detects unknown-session errors |
| `pi-stream.ts` | `pi_local` | JSON mode output → display text, errors, usage |
| `gemini-stream.ts` | `gemini_local` | Provider-specific streaming format |

Each module exposes a factory function (`create*Accumulator`), a streaming consumer (`consume*`), and a flush function to drain any buffered partial line at process exit.

---

## Error Classification

Every adapter implements `classifyError(stderr, exitCode)` using a two-layer composition:

```
classifyChain(stderr, exitCode, [
  providerSpecificClassifier?,   // e.g. classifyCodexModelUnavailable
  (s, c) => classifyCommonError(s, c, { providerDisplayName, cliCommand }),
])
```

`classifyCommonError` in `error-classification.ts` covers seven canonical `ConversationErrorKind` values:

| Kind | Trigger pattern |
|---|---|
| `cli_not_found` | `ENOENT`, `command not found`, `spawn ... ENOENT` |
| `auth_expired` | `not logged in`, `unauthorized`, `missing api key`, `401`, `403`, `token expired` |
| `rate_limited` | `rate-limit`, `429`, `too many requests`, `resource exhausted` |
| `session_expired` | `no conversation found`, `session not found`, `resume invalid` |
| `context_exceeded` | `context length exceeded`, `prompt too long`, `tokens exceed` |
| `transport` | `ECONNREFUSED`, `ECONNRESET`, `socket hang up`, `fetch failed` |
| `timeout` | `timed out`, `deadline exceeded` |

`classifyChain` stops at the first non-null result and falls back to `unknownClassification()`.

Sources: [src/lib/agents/adapters/error-classification.ts:20-100]()

---

## Session Codec

Adapters that support session resume attach an `AdapterSessionCodec`:

```typescript
interface AdapterSessionCodec {
  deserialize(raw: unknown): Record<string, unknown> | null;
  serialize(params: Record<string, unknown>): Record<string, unknown> | null;
  getDisplayId?(params: Record<string, unknown>): string | null;
}
```

The runner stores the serialized value alongside the run record and passes it back through `sessionParams` on the next turn. Before calling `execute()`, it calls `deserialize`, extracts `sessionId`, and passes it directly. The display ID is surfaced in the conversation header.

| Adapter | Session key | Example display |
|---|---|---|
| `claude_local` | `resumeId` | `Claude · a1b2c3d4` |
| `codex_local` | `threadId` | `Codex · a1b2c3d4` |
| `cursor_local` | `sessionId` | `Cursor · a1b2c3d4` |
| `opencode_local` | `sessionId` | provider-specific |
| `pi_local` | `sessionFile` | filename without extension |

Sources: [src/lib/agents/adapters/types.ts:76-84](), [src/lib/agents/adapters/claude-local.ts:14-32](), [src/lib/agents/adapters/pi-local.ts:63-80]()

---

## Plugin Loader

Third-party adapters are loaded from `~/.cabinet/adapter-plugins.json`:

```json
{
  "plugins": [
    { "package": "my-cabinet-adapter", "enabled": true, "type": "my_adapter" },
    { "path": "./local-adapter.js", "enabled": true }
  ]
}
```

`loadExternalAdapters()` in `plugin-loader.ts` is a promise-singleton (idempotent after the first call). For each enabled entry it:

1. Resolves the module path (absolute, relative to `cwd`, or bare package name).
2. Dynamic-`import()`s the module.
3. Calls `extractAdapter()`: tries `createAgentAdapter()`, then `createServerAdapter()`, then `module.default`, then `module.adapter` — whatever returns an object with a `type: string` field.
4. Optionally validates the `type` against the entry's declared `type` field.
5. Calls `agentAdapterRegistry.registerExternal(adapter)`.

`unloadExternalAdapters()` iterates `loadedPlugins` and calls `agentAdapterRegistry.unregisterExternal(type)` for each, restoring any displaced built-in.

Sources: [src/lib/agents/adapters/plugin-loader.ts:56-100](), [src/lib/agents/adapters/plugin-loader.ts:101-120]()

---

## Provider Registry

`ProviderRegistryImpl` (`provider-registry.ts`) is a separate singleton from the adapter registry. It holds `AgentProvider` objects — richer descriptions that include install instructions, binary candidates, `healthCheck()`, and `buildArgs`/`buildSessionInvocation`/`buildOneShotInvocation` contracts for PTY-based launch paths.

```typescript
export const providerRegistry = new ProviderRegistryImpl();
// default: "claude-code"

providerRegistry.register(claudeCodeProvider);
providerRegistry.register(codexCliProvider);
// … six more built-in providers
```

`listAvailable()` calls `provider.isAvailable()` on each entry concurrently and returns only those that pass. `providerSupportsEffort(providerId, effort)` is a utility used by the dispatcher to decide whether to forward a parent run's effort level to a child run when the provider differs.

Sources: [src/lib/agents/provider-registry.ts:12-55]()

---

## Per-Run Provider & Model Overrides at Launch Time

`provider-runtime.ts` is the bridge between stored run config and the actual subprocess. For **legacy PTY** runs, `buildLaunchSpec` resolves the provider, calls the appropriate `buildOneShotInvocation` or `buildSessionInvocation`, and returns a `ProviderLaunchSpec` (command + args). The override flow is:

```
getOneShotLaunchSpec({ providerId?, prompt, workdir, model?, effort?, resumeId? })
  → resolveProviderOrThrow(providerId)          // reads settings, resolves enabled provider
    → readProviderSettingsSync()
    → resolveEnabledProviderId(providerId, settings)
    → providerRegistry.get(resolvedId)
  → provider.buildOneShotInvocation(prompt, workdir, { model, effort })
  → resolveCliCommand(provider)                 // walks commandCandidates list
  → ProviderLaunchSpec { command, args, … }
```

For **structured** runs the runner bypasses `provider-runtime.ts` and calls `agentAdapterRegistry.get(adapterType)` directly, then passes `config.model` and `config.effort` through `AdapterExecutionContext.config`. The adapter reads them with `readStringConfig(config, "model")` and `readEffortConfig(config)` from `_shared/cli-args.ts` and appends the appropriate CLI flags.

Sources: [src/lib/agents/provider-runtime.ts:22-60](), [src/lib/agents/adapters/claude-local.ts:38-50](), [src/lib/agents/adapters/codex-local.ts:75-93]()

---

## Shared Subprocess Utilities

`utils.ts` provides the runtime infrastructure used by every structured adapter:

- **`ADAPTER_RUNTIME_PATH`** — A composed `PATH` string that includes `~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, the active nvm bin, and the inherited `process.env.PATH`. Set as the `PATH` env var on every child process.
- **`withAdapterRuntimeEnv(env)`** — Merges values from `~/.cabinet.env` (mtime-cached) under the adapter runtime PATH. Caller-supplied env takes precedence over file values (dotenv convention).
- **`runChildProcess(command, args, options)`** — Spawns the child with `stdio: pipe`, wires `onStdout`/`onStderr` streaming callbacks, and manages timeout with a two-stage SIGTERM → SIGKILL (5 s grace period by default). On POSIX it kills the entire process group (`process.kill(-processGroupId, signal)`) to prevent orphaned subprocesses.
- **`resolveCommandFromCandidates(candidates)`** — Walks a list of path candidates, using `test -x` for absolute paths and `command -v` for bare names.

Sources: [src/lib/agents/adapters/utils.ts:10-50](), [src/lib/agents/adapters/utils.ts:97-155]()

---

## Summary

The adapter layer cleanly separates _what tool to use_ (provider registry) from _how to invoke it_ (adapter registry). Each of the eight built-in `*_local` adapters owns a dedicated arg-builder, an optional stream parser, a session codec, and a `classifyError` chain. Legacy `*_legacy` adapters share the same registry slots but carry `executionEngine: "legacy_pty_cli"` and expose no `execute()` method — the PTY runtime handles them directly. External adapters slot in via `~/.cabinet/adapter-plugins.json` using a module-agnostic export contract (`createAgentAdapter`, `default`, or `adapter`), with a built-in fallback preserved on displacement. Per-run model and effort overrides are threaded through `AdapterExecutionContext.config` for structured adapters and through `buildOneShotInvocation` opts for PTY adapters.

---

## 04. Agent Runtime — Conversations, Skills & Scheduled Jobs

> End-to-end lifecycle of an agent run: persona templating (persona-manager.ts), conversation creation and transcript storage (conversation-store.ts, conversation-runner.ts), skill loading and scoping (skills/loader.ts, skills/scope.ts), heartbeat polling (heartbeat.ts), action parsing and dispatching (action-parser.ts, action-dispatcher.ts), and the cron-based job scheduler (jobs/job-manager.ts, job-normalization.ts). Includes the agent library of 20 pre-built templates.

- Page Markdown: https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/04-agent-runtime-conversations-skills-scheduled-jobs.md
- Generated: 2026-05-27T06:29:10.340Z

### Source Files

- `src/lib/agents/conversation-runner.ts`
- `src/lib/agents/conversation-store.ts`
- `src/lib/agents/persona-manager.ts`
- `src/lib/agents/skills/loader.ts`
- `src/lib/agents/skills/scope.ts`
- `src/lib/agents/action-dispatcher.ts`
- `src/lib/agents/heartbeat.ts`
- `src/lib/jobs/job-manager.ts`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [src/lib/agents/conversation-runner.ts](src/lib/agents/conversation-runner.ts)
- [src/lib/agents/conversation-store.ts](src/lib/agents/conversation-store.ts)
- [src/lib/agents/persona-manager.ts](src/lib/agents/persona-manager.ts)
- [src/lib/agents/persona-templating.ts](src/lib/agents/persona-templating.ts)
- [src/lib/agents/heartbeat.ts](src/lib/agents/heartbeat.ts)
- [src/lib/agents/skills/loader.ts](src/lib/agents/skills/loader.ts)
- [src/lib/agents/skills/scope.ts](src/lib/agents/skills/scope.ts)
- [src/lib/agents/action-parser.ts](src/lib/agents/action-parser.ts)
- [src/lib/agents/action-dispatcher.ts](src/lib/agents/action-dispatcher.ts)
- [src/lib/agents/library-manager.ts](src/lib/agents/library-manager.ts)
- [src/lib/jobs/job-manager.ts](src/lib/jobs/job-manager.ts)
- [src/lib/jobs/job-normalization.ts](src/lib/jobs/job-normalization.ts)
</details>

# Agent Runtime — Conversations, Skills & Scheduled Jobs

Cabinet's agent runtime coordinates every step between a user message (or cron trigger) and a persisted, structured result. It spans persona loading, prompt assembly, skill injection, daemon-session management, heartbeat scheduling, action parsing, and the cron-based job system. Understanding this pipeline is essential for extending agents, debugging failed runs, or reasoning about multi-agent task delegation.

This page follows the lifecycle end-to-end: how a persona is read and rendered into a system prompt, how a conversation is created and stored, how skills are resolved and injected, how the runtime polls for completion, how agents propose follow-on actions, and how those actions become new runs or persistent scheduled jobs.

---

## Overall Architecture

```text
                    ┌─────────────────────────────────────┐
                    │           Persona Manager            │
                    │  persona.md (gray-matter) → in-mem  │
                    │  resolution: cabinet-local > global  │
                    │  > any-cabinet scan                  │
                    └──────────────┬──────────────────────┘
                                   │ AgentPersona
                    ┌──────────────▼──────────────────────┐
                    │         conversation-runner           │
                    │  buildManualConversationPrompt()      │
                    │  buildEditorConversationPrompt()      │
                    │  startJobConversation()               │
                    │  ├── Skills: resolveDesiredSkills()  │
                    │  │          buildSkillIndex()         │
                    │  │          prepareSkillMount()       │
                    │  └── persona-templating.renderBody() │
                    └──────────────┬──────────────────────┘
                                   │ ConversationMeta
                    ┌──────────────▼──────────────────────┐
                    │         conversation-store            │
                    │  createConversation() → dir layout   │
                    │  meta.json / prompt.md / transcript  │
                    │  appendConversationTranscript()       │
                    │  finalizeConversation() → parse      │
                    │  cabinet block → artifact paths       │
                    └──────────────┬──────────────────────┘
                                   │
              ┌────────────────────┼──────────────────────┐
              │                    │                       │
  ┌───────────▼──────────┐  ┌──────▼──────────┐  ┌───────▼────────────┐
  │    heartbeat.ts       │  │  action-parser  │  │    job-manager     │
  │  runHeartbeat()       │  │  parseAgent     │  │  loadAllJobs()     │
  │  processOutput()      │  │  Actions()      │  │  executeJob()      │
  │  memory block parse   │  └──────┬──────────┘  │  saveAgentJob()    │
  │  goal/task/slack      │         │              └────────────────────┘
  └───────────────────────┘  ┌──────▼──────────┐
                             │ action-dispatcher│
                             │ dispatchApproved │
                             │ Actions()        │
                             │ LAUNCH_TASK      │
                             │ SCHEDULE_JOB     │
                             │ SCHEDULE_TASK    │
                             └─────────────────┘
```

---

## Persona Management

### Persona Files and the `AgentPersona` Structure

Every agent is backed by a `persona.md` file parsed with `gray-matter`. The frontmatter carries all runtime configuration; the markdown body is the agent's instruction text.

Key `AgentPersona` fields:

| Field | Type | Purpose |
|---|---|---|
| `slug` | `string` | Unique identifier; also the directory name |
| `name` | `string` | Display name |
| `role` | `string` | Short role description surfaced in the roster |
| `provider` | `string` | LLM provider id (e.g. `claude-code`) |
| `adapterType` | `string?` | Execution adapter override |
| `heartbeat` | `string` | Cron expression (`0 8 * * *` default) |
| `budget` | `number` | Max heartbeats per month (default: 100) |
| `active` / `heartbeatEnabled` | `boolean` | Controls cron registration |
| `workdir` | `string` | Working directory for adapter (`/data` default) |
| `skills` | `string[]?` | Skill keys attached to this persona |
| `canDispatch` | `boolean?` | May propose LAUNCH_TASK/SCHEDULE_JOB actions |
| `type` | `"lead"` \| `"specialist"` | `lead` defaults to `canDispatch: true` |
| `scope` | `"global"` \| `"cabinet"` | Computed from where the file was found |
| `body` | `string` | Raw markdown body (instructions) |

Sources: [src/lib/agents/persona-manager.ts:155-225](src/lib/agents/persona-manager.ts)

### Resolution Order

`readPersona(slug, preferredCabinetPath)` searches in this order:

1. **Cabinet-local**: `data/<cabinet>/.agents/<slug>/persona.md` — per-cabinet override
2. **Global tier**: `data/.global-agents/<slug>/persona.md` — shared across all cabinets; the `editor` agent is bootstrapped here at startup via `ensureGlobalAgents()`
3. **Any cabinet scan**: walks all discovered cabinet paths until a match is found

This means a cabinet-local persona always shadows a global persona of the same slug. The `editor` slug is the canonical generalist and is resolved as a global fallback from `readEditorConversationPrompt` when no cabinet-local editor exists.

Sources: [src/lib/agents/persona-manager.ts:80-106](src/lib/agents/persona-manager.ts)

### Persona Templating

`renderPersonaBody()` in `persona-templating.ts` substitutes `{{ key.path }}` placeholders at prompt-build time so persona files never bake in cabinet-specific names. Supported variables:

| Placeholder | Source |
|---|---|
| `{{cabinet.name}}` | Cabinet manifest name |
| `{{cabinet.slug}}` / `{{cabinet.path}}` | Cabinet identity |
| `{{user.name}}` | User profile display name |
| `{{agent.name}}` / `{{agent.slug}}` | Persona's own name and slug |
| `{{today}}` | ISO date (caller-supplied) |

Unknown placeholders are left intact rather than silently dropped.

Sources: [src/lib/agents/persona-templating.ts:1-57](src/lib/agents/persona-templating.ts)

---

## Conversation Lifecycle

### Prompt Construction

`buildManualConversationPrompt()` assembles the full system prompt by layering:

1. **Cabinet requirement header** — reminds the agent of the required `cabinet` fenced block
2. **Locale instructions** — `buildLocaleInstructions()` injects language and RTL frontmatter instructions (Hebrew, etc.)
3. **Agent context header** — `buildAgentContextHeader()` renders the persona body through the template engine
4. **Skill index** — `buildSkillIndex(bundles)` injects a compact list of attached skills
5. **KB scope instructions** — tells the agent its root data directory and cabinet scope
6. **Epilogue instructions** — the `cabinet` block spec, optional dispatch roster (LAUNCH_TASK / SCHEDULE_JOB), and the list of available teammates
7. **User request** — extracted mention context and attachment paths appended inline

`buildEditorConversationPrompt()` follows the same structure but also prepends the target page path. `startJobConversation()` uses the same layered approach, substituting `{{date}}`, `{{job.name}}`, `{{job.id}}`, and `{{job.workdir}}` into the job's prompt before assembly.

Sources: [src/lib/agents/conversation-runner.ts:278-380](src/lib/agents/conversation-runner.ts)

### Conversation Creation and Storage

`createConversation()` generates a deterministic ID and writes the following directory structure atomically:

```text
{conversations_dir}/{id}/
  meta.json          ← ConversationMeta (status, agentSlug, trigger, paths, tokens...)
  prompt.md          ← Full system prompt (as sent to the adapter)
  transcript.txt     ← Raw adapter output (appended live during streaming)
  mentions.json      ← Referenced KB page paths
  artifacts.json     ← Parsed ARTIFACT: entries from the cabinet block
```

The conversation ID encodes `{ISO-timestamp}-{sha1-cabinet-scope[8]}-{agentSlug}-{trigger}[-{jobName}]`. Cabinet scope is a SHA-1 hash so IDs remain unique across cabinets even when agent slugs collide.

`appendConversationTranscript()` appends raw output to `transcript.txt` and publishes a throttled `task.updated` SSE event (at most once per 500 ms) so the UI can stream partial output live.

Sources: [src/lib/agents/conversation-store.ts:261-322](src/lib/agents/conversation-store.ts), [src/lib/agents/conversation-store.ts:201-213](src/lib/agents/conversation-store.ts)

### Polling and Completion

After spawning the daemon session, `waitForConversationCompletion()` polls `getDaemonSessionOutput()` in two phases:

| Phase | Duration | Interval |
|---|---|---|
| Cold-start (fast poll) | First 5 seconds | 250 ms |
| Steady-state | Until 15-minute deadline | 700 ms |

On every poll, if the transcript grew, a `task.updated` event is published. When the daemon reports a terminal status (`completed` or `failed`), `finalizeConversation()` is called and a completion toast notification is enqueued.

Sources: [src/lib/agents/conversation-runner.ts:572-638](src/lib/agents/conversation-runner.ts)

### The Cabinet Block Protocol

Every agent turn must end with a fenced `cabinet` block:

```
```cabinet
SUMMARY: one short summary line
CONTEXT: optional memory note
ARTIFACT: path/to/file.md
ARTIFACT: path/to/other.md
```
```

`parseCabinetBlock()` extracts SUMMARY, CONTEXT, and all ARTIFACT paths from the last such block in the output. The parser handles:
- Prompt echo stripping (lines that appear verbatim in the system prompt are ignored)
- ANSI escape code removal
- Multi-file ARTIFACT lines (comma- or semicolon-separated, split and normalized)
- Placeholder detection (e.g. `SUMMARY: one short summary line` is treated as unfilled and dropped)
- Path guards: artifact paths must contain a directory separator or known file extension; prose-like values are rejected

If the run completes but no usable `cabinet` block is found, `isCabinetBlockMissing()` returns `true` and `waitForConversationCompletion()` issues a single automatic follow-up (`continueConversationRun`) asking the agent to emit only the block. A second miss is accepted as-is.

Sources: [src/lib/agents/conversation-store.ts:371-440](src/lib/agents/conversation-store.ts), [src/lib/agents/conversation-store.ts:601-612](src/lib/agents/conversation-store.ts)

### Multi-Turn Continuation

`continueConversationRun()` supports two modes:

- **Resume mode**: the underlying adapter session is still alive; only the epilogue instructions and any newly-mentioned skills are prepended before the follow-up user message, keeping the prompt compact.
- **Replay mode**: the session has expired or the adapter is stateless; the full system prompt (persona, skill index, KB scope, history serialized as `<turn-user>/<turn-assistant>` XML) is rebuilt from scratch.

Session codec blobs (for providers that carry opaque resume state) are persisted to `session.json` adjacent to the conversation directory and read back on the next continuation.

Sources: [src/lib/agents/conversation-runner.ts:700-800](src/lib/agents/conversation-runner.ts)

---

## Skills System

### Origins and Precedence

`listSkills()` / `readSkill()` walk five origins in priority order:

| Priority | Origin key | Physical location |
|---|---|---|
| 1 (highest) | `cabinet-scoped` | `data/<cabinet>/.agents/skills/<key>/` |
| 2 | `cabinet-root` | `<project>/.agents/skills/<key>/` |
| 3 | `linked-repo` | (reserved; not yet wired) |
| 4 | `system` | `~/.claude/skills/`, `~/.agents/skills/`, Claude plugin marketplace layouts |
| 5 (lowest) | `legacy-home` | `~/.cabinet/skills/<key>/` |

When the same key exists at multiple levels, the highest-precedence entry wins. Claude plugin marketplace skills are tagged with `pluginSource` (marketplace + plugin name) to avoid key collisions between plugins.

Sources: [src/lib/agents/skills/loader.ts:230-275](src/lib/agents/skills/loader.ts)

### Skill Bundles and File Inventory

Each skill directory is scanned by `walkBundle()` which classifies every file:

| Kind | Criteria |
|---|---|
| `skill` | Exactly `SKILL.md` |
| `reference` | Under `references/` or `rules/` |
| `asset` | Under `assets/`, or image/data extensions |
| `script` | Under `scripts/`, executable bit set, or script extensions (`.sh`, `.py`, `.ts`, etc.) |
| `markdown` | `.md` files |
| `other` | Everything else |

`deriveTrustLevel()` rolls up the inventory: `markdown_only` if all files are markdown, `assets` if any non-markdown non-script files exist, `scripts_executables` if any scripts are present. The trust level surfaces in the skill picker UI but does not gate runtime loading.

Sources: [src/lib/agents/skills/loader.ts:47-100](src/lib/agents/skills/loader.ts)

### Skill Loading at Run Time

The conversation runner calls `resolveDesiredSkills(mergedKeys, cabinetPath)` to hydrate slug lists into `SkillBundle[]`. The merged key set combines:
- The persona's persisted `skills:` frontmatter list
- Any `@skill-name` mentions from the composer (run-only, not persisted)

`buildSkillIndex(bundles)` formats a compact bullet list injected into the system prompt so the model knows which skills are available. The physical skill directories are separately mounted into a per-session tmpdir via `prepareSkillMount()` and forwarded to the adapter (e.g. via `--add-dir` for Claude Code). The tmpdir is cleaned up when `finalizeConversation()` runs.

Sources: [src/lib/agents/skills/loader.ts:305-360](src/lib/agents/skills/loader.ts), [src/lib/agents/conversation-runner.ts:375-420](src/lib/agents/conversation-runner.ts)

### Scope Validation

`resolveSkillsScopeRoot()` in `skills/scope.ts` validates API-provided scope strings:

- `"root"` or `undefined` → `<project>/.agents/skills/`
- `"cabinet:<path>"` → validated through `resolveContentPath()` which enforces the `DATA_DIR` boundary (no `../` traversal out of the data directory)

Skill key validation: `isValidSkillKey()` accepts only `[a-z0-9][a-z0-9-]*`, max 64 characters.

Sources: [src/lib/agents/skills/scope.ts:1-55](src/lib/agents/skills/scope.ts)

---

## Heartbeat System

### Scheduling

`registerHeartbeat(slug, cronExpr)` wraps `node-cron.schedule()`. On app boot, `registerAllHeartbeats()` reads all personas and registers cron jobs for those where `active && heartbeatEnabled && heartbeatsUsed < budget`. Budget is tracked in `memory/stats.json` alongside `lastHeartbeat`. The `nextHeartbeat` field on `AgentPersona` is computed from the cron expression and `lastHeartbeat` via `computeNextCronRun()`.

Sources: [src/lib/agents/persona-manager.ts:390-420](src/lib/agents/persona-manager.ts)

### Context Assembly

`buildHeartbeatContext()` assembles the heartbeat prompt by reading:

| Source | Description |
|---|---|
| `memory/context.md` | Rolling context from previous heartbeats (last 20 entries) |
| `memory/decisions.md` | Append-only log of key decisions |
| `memory/learnings.md` | Long-term insights |
| Inbox | Unread messages from other agents |
| Focus-area `index.md` files | First 500 bytes of each focus path |
| Goal state | Current values from `goal-manager.ts` against targets/floors |
| Task inbox | Pending and in-progress tasks assigned to this agent |

The persona body goes through `renderPersonaBody()` with cabinet name, user name, and today's date substituted in.

Sources: [src/lib/agents/heartbeat.ts:12-100](src/lib/agents/heartbeat.ts)

### The Memory Block Protocol

The agent's heartbeat response is expected to include a `memory` fenced block:

```
```memory
CONTEXT_UPDATE: What happened this heartbeat.
DECISION: A key decision, with reasoning.
LEARNING: A new long-term insight.
GOAL_UPDATE [metric_name]: +N
MESSAGE_TO [agent-slug]: Message text.
SLACK [channel-name]: Message to post.
TASK_CREATE [target-agent] [priority]: title | description
TASK_COMPLETE [task-id]: result summary
```
```

`processHeartbeatOutput()` parses this block and dispatches each directive:

- `CONTEXT_UPDATE` → appends to `context.md`, trims to last 20 entries
- `DECISION` / `LEARNING` → append-only writes to their respective files
- `GOAL_UPDATE` → calls `updateGoal()` to increment metrics
- `MESSAGE_TO` → calls `sendMessage()` to write to another agent's inbox
- `SLACK` → calls `postMessage()` to Agent Slack
- `TASK_CREATE` → creates a structured task handoff with `createTask()`
- `TASK_COMPLETE` → marks a task done with `updateTask()`

Goal floor alerts fire a Slack alert to the `alerts` channel when a metric is below its floor with ≥80% of the period elapsed.

**Auto-pause**: after 3 consecutive heartbeat failures, the persona is written back with `active: false` and a Slack alert is posted. Failure recovery requires manual re-activation.

Sources: [src/lib/agents/heartbeat.ts:130-230](src/lib/agents/heartbeat.ts)

---

## Action Parsing and Dispatching

### Parsing Agent Actions

`parseAgentActions(output, prompt)` extracts structured actions from agent output in three passes, preferring more-explicit forms:

1. **`cabinet-actions` JSON blocks** (highest authority): parsed first; supports multi-line prompts and full JSON field names. An array of action objects or a `{"actions": [...]}` wrapper.
2. **Inline markers inside `cabinet` blocks**: `LAUNCH_TASK: <slug> | <title> | <prompt>`, `SCHEDULE_JOB: <slug> | <name> | <cron> | <prompt>`, `SCHEDULE_TASK: <slug> | <ISO> | <title> | <prompt>`
3. **Stray inline markers**: same syntax, outside any fenced block (fallback for CLIs that strip fencing)

Deduplication: each parsed action is fingerprinted (type + agent + title/name + prompt hash); duplicates and prompt-echo actions (fingerprints that appear verbatim in the system prompt) are silently dropped. A maximum of `MAX_ACTIONS_PER_TURN` actions are kept; additional ones set `truncated: true`.

Runtime hints (`model=`, `effort=`, `provider=`, `adapterType=`) can be appended as `key=value` segments after the pipe-delimited fields.

Sources: [src/lib/agents/action-parser.ts:1-80](src/lib/agents/action-parser.ts)

### Dispatch Types and Runtime Inheritance

`dispatchApprovedActions()` iterates human-approved actions and dispatches each:

```mermaid
stateDiagram-v2
    [*] --> PendingAction : agent proposes (cabinet block)
    PendingAction --> AwaitingApproval : finalizeConversation
    AwaitingApproval --> Dispatched : human approves
    AwaitingApproval --> Rejected : unknown agent / bad params
    Dispatched --> RunningConversation : LAUNCH_TASK
    Dispatched --> ScheduledJob : SCHEDULE_JOB
    Dispatched --> ScheduledTask_OneShot : SCHEDULE_TASK (future)
    Dispatched --> RunningConversation : SCHEDULE_TASK (≤60s)
    RunningConversation --> [*]
    ScheduledJob --> [*]
    ScheduledTask_OneShot --> [*]
```

**Runtime inheritance** for sub-tasks follows this precedence (highest → lowest):

1. Action-authored override (agent explicitly set `model=` / `effort=` on the action)
2. Parent conversation's runtime (provider + model + effort propagate by default)
3. Target persona's defaults

Model strings are only inherited when the resolved provider matches the parent's (cross-provider model IDs are rejected). Effort levels are portable when the target provider declares support for that level.

After dispatching LAUNCH_TASK, `tagLineage()` writes `parentTaskId`, `triggeringAgent`, and `spawnDepth` onto the spawned conversation's metadata.

SCHEDULE_TASK that is more than 60 s in the future is converted to a one-shot cron job (`oneShot: true`, `runAfter: ISO`) rather than a standing recurring schedule.

Sources: [src/lib/agents/action-dispatcher.ts:20-80](src/lib/agents/action-dispatcher.ts), [src/lib/agents/action-dispatcher.ts:103-200](src/lib/agents/action-dispatcher.ts)

---

## Cron-Based Job Scheduler

### Job Storage

Jobs are persisted as YAML files at `{cabinet}/.jobs/{id}.yaml`. Each file contains a normalized `JobConfig`:

| Field | Default | Description |
|---|---|---|
| `id` | kebab-case from `name` | Unique identifier |
| `name` | — | Human-readable label |
| `schedule` | `"0 9 * * *"` | Cron expression |
| `enabled` | `true` | Whether cron fires this job |
| `provider` | `"claude-code"` | LLM provider |
| `adapterType` | derived from `provider` | Execution adapter |
| `timeout` | `600` (seconds) | Run-level timeout |
| `prompt` | — | Template-variable-aware instructions |
| `on_complete` / `on_failure` | `[]` | Post-action hooks (e.g. `git_commit`) |
| `oneShot` | `false` | Disable after first fire |
| `runAfter` | — | ISO datetime guard for one-shot tasks |
| `ownerTaskId` | — | Traceability back to the spawning conversation |
| `cabinetPath` | — | Cabinet scope |

`normalizeJobConfig()` validates and applies all defaults; `normalizeJobId()` produces a kebab-case ID from the `name` when `id` is absent. On every `loadNormalizedJobFile()` call the YAML is re-written if normalization changed anything, keeping files on disk always canonical.

Sources: [src/lib/jobs/job-normalization.ts:1-105](src/lib/jobs/job-normalization.ts), [src/lib/jobs/job-manager.ts:55-105](src/lib/jobs/job-manager.ts)

### Execution

`executeJob(job)` → `startJobConversation(job)` → `startConversationRun(...)`. The job prompt has `{{date}}`, `{{datetime}}`, `{{job.name}}`, `{{job.id}}`, and `{{job.workdir}}` substituted at fire time. Post-actions (currently only `git_commit`) run on completion or failure via `processPostActions()`.

`initScheduler()` and `scheduleJob()` both delegate to `reloadDaemonSchedules()` — the in-process cron map is managed by the Cabinet daemon process (which also owns the `node-cron` jobs for heartbeats), so schedule changes are applied by sending a reload signal rather than directly mutating the map from Next.js.

Sources: [src/lib/jobs/job-manager.ts:130-175](src/lib/jobs/job-manager.ts), [src/lib/agents/conversation-runner.ts:840-900](src/lib/agents/conversation-runner.ts)

---

## Agent Library Templates

Cabinet ships **42 pre-built persona templates** under `src/lib/agents/library/` (seeded at runtime into `data/.agents/.library/`). Each template is a `persona.md` with frontmatter and a rich instruction body. They span professional, personal, and creative domains:

**Executive / Business:** `ceo`, `coo`, `cto`, `cfo`, `product-manager`, `legal`, `people-ops`, `sales`, `customer-success`

**Content / Writing:** `editor` (global default generalist), `copywriter`, `writing-coach`, `content-marketer`, `script-writer`, `post-optimizer`, `social-media`, `seo`

**Research / Analysis:** `researcher`, `research-lead`, `data-analyst`, `lit-reviewer`, `trend-scout`, `citation-keeper`, `note-synthesizer`

**Engineering / Design:** `devops`, `qa`, `tinkerer`, `ux-designer`, `image-creator`

**Personal / Life:** `home-manager`, `meal-planner`, `grocery-buyer`, `budget-keeper`, `calendar-keeper`, `habit-tracker`, `planner`, `kid-coordinator`, `inbox-triage`

**Education / Knowledge:** `teaching-assistant`, `librarian`, `assistant`, `growth-marketer`

`resolveAgentLibraryDir()` checks the seeded data path first, falling back to the source tree so templates are always available in development. `ensureGlobalAgents()` bootstraps the `editor` as a shared global agent on first boot, since it serves as the fallback generalist across all cabinets.

Sources: [src/lib/agents/library-manager.ts:14-50](src/lib/agents/library-manager.ts), [src/lib/agents/library-manager.ts:120-160](src/lib/agents/library-manager.ts)

---

## Summary

The agent runtime is a layered pipeline where each module has a single, well-bounded responsibility. `persona-manager.ts` owns file-backed identity and cron registration; `conversation-runner.ts` assembles prompts and drives the daemon lifecycle; `conversation-store.ts` owns all durable conversation state and the `cabinet` block parsing protocol; the skills subsystem resolves a precedenced multi-origin catalog and injects it as both a prompt index and a mounted directory; `heartbeat.ts` drives proactive agent behavior and the structured `memory` block protocol; `action-parser.ts` + `action-dispatcher.ts` implement the human-in-the-loop proposal→approval→spawn flow for multi-agent delegation; and `job-manager.ts` + `job-normalization.ts` provide the cron-backed scheduled task layer. The library of 42 templates makes this infrastructure immediately usable for a wide range of roles.

---

## 05. File-Based Storage & Git-Backed History

> How Cabinet stores all knowledge as markdown files on disk with no traditional database: path conventions (path-utils.ts), page I/O with gray-matter front-matter (page-io.ts), the virtual file tree builder (tree-builder.ts), low-level filesystem operations (fs-operations.ts), and the simple-git integration that auto-commits every save and powers the full diff viewer (git-service.ts). Covers cabinet discovery, multi-cabinet support, and the data-dir layout.

- Page Markdown: https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/05-file-based-storage-git-backed-history.md
- Generated: 2026-05-27T06:37:40.518Z

### Source Files

- `src/lib/storage/path-utils.ts`
- `src/lib/storage/page-io.ts`
- `src/lib/storage/tree-builder.ts`
- `src/lib/storage/fs-operations.ts`
- `src/lib/git/git-service.ts`
- `src/lib/cabinets/discovery.ts`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [src/lib/storage/path-utils.ts](src/lib/storage/path-utils.ts)
- [src/lib/storage/page-io.ts](src/lib/storage/page-io.ts)
- [src/lib/storage/tree-builder.ts](src/lib/storage/tree-builder.ts)
- [src/lib/storage/fs-operations.ts](src/lib/storage/fs-operations.ts)
- [src/lib/storage/order-store.ts](src/lib/storage/order-store.ts)
- [src/lib/git/git-service.ts](src/lib/git/git-service.ts)
- [src/lib/cabinets/discovery.ts](src/lib/cabinets/discovery.ts)
- [src/lib/cabinets/files.ts](src/lib/cabinets/files.ts)
- [src/lib/runtime/runtime-config.ts](src/lib/runtime/runtime-config.ts)
</details>

# File-Based Storage & Git-Backed History

Cabinet uses no traditional database. Every knowledge page is a Markdown file on disk, with structured metadata carried as YAML front-matter inside the same file. This architecture gives users transparent ownership of their data, enables standard text tooling (grep, diff, editors) to work on the knowledge base, and makes full-history versioning a natural fit for Git.

This page explains how the storage layer is structured: where data lives (`path-utils.ts`), how individual pages are read and written (`page-io.ts`), how the entire tree is turned into a virtual file tree for the sidebar (`tree-builder.ts`), what low-level I/O primitives underpin everything (`fs-operations.ts`), how cabinets are discovered across the data directory (`discovery.ts`), and how every save is automatically committed to a local Git repository (`git-service.ts`).

---

## Data Directory Layout

All user content lives under a single root called `DATA_DIR`. The resolved path depends on the runtime environment, resolved in priority order:

| Priority | Source | Value |
|----------|--------|-------|
| 1 | `CABINET_DATA_DIR` env var | Any absolute path |
| 2 | `.cabinet-install.json` config file | `json.dataDir` field |
| 3 | Electron default (macOS / Windows) | `~/Documents/Cabinet` |
| 4 | Electron default (Linux) | `~/Cabinet` |
| 5 | Source-run fallback | `<project-root>/data` |

Sources: [src/lib/runtime/runtime-config.ts:56-72]()

Inside `DATA_DIR`, the structure is entirely user-controlled directories and files. Cabinet maintains a small hidden directory `.cabinet-state/` at the data root for runtime metadata such as install state and update status, but this is never shown in the knowledge base UI.

```text
DATA_DIR/
├── .cabinet-state/        ← internal runtime state (hidden)
│   ├── install.json
│   └── update-status.json
├── my-cabinet/            ← a named cabinet (contains .cabinet manifest)
│   ├── .cabinet           ← marks this dir as a cabinet
│   ├── index.md           ← cabinet root page
│   ├── notes/
│   │   ├── index.md
│   │   └── meeting-notes/
│   │       └── index.md
│   └── .cabinet-order.yaml   ← sibling ordering sidecar
└── another-page/
    └── index.md
```

---

## Path Conventions (`path-utils.ts`)

The storage layer operates with two path namespaces:

- **Virtual path**: a slash-separated string relative to `DATA_DIR`, e.g. `my-cabinet/notes/meeting-2024`. This is what the UI and API use.
- **Filesystem path**: the absolute on-disk path. All operations translate from virtual → absolute before any I/O.

`resolveContentPath(virtualPath)` performs this translation and enforces a **path traversal guard**: if the resolved absolute path does not begin with `DATA_DIR`, an error is thrown immediately.

```ts
// src/lib/storage/path-utils.ts
export function resolveContentPath(virtualPath: string): string {
  const resolved = path.resolve(DATA_DIR, virtualPath);
  if (!resolved.startsWith(DATA_DIR)) {
    throw new Error("Path traversal detected");
  }
  return resolved;
}
```

The reverse direction — from an absolute filesystem path back to a virtual path — is handled by `virtualPathFromFs(fsPath)`, which strips the `DATA_DIR` prefix and the leading slash.

`isHiddenEntry(name)` controls which filesystem entries are invisible to the UI. It hides dot-files and the standard build/dependency directories (`node_modules`, `dist`, `build`, etc.).

Sources: [src/lib/storage/path-utils.ts:1-43]()

---

## Page I/O with Gray-Matter Front-Matter (`page-io.ts`)

Every knowledge page is a Markdown file whose metadata is embedded as a YAML front-matter block parsed by the `gray-matter` library. The canonical shape of a stored page file is:

```markdown
---
title: Meeting Notes
created: 2024-01-15T09:00:00.000Z
modified: 2024-01-15T09:00:00.000Z
tags: []
order: 200
---

# Meeting Notes

Body content here...
```

### Page Resolution Strategy

`readPage(virtualPath)` resolves which file to open in this order:

1. `<resolved>/index.md` — preferred, treats every page as a directory
2. `<resolved>.md` — legacy flat-file form
3. `<resolved>` directly — non-Markdown files (images, code, etc.)
4. `.cabinet-meta` sidecar in a linked directory — fallback for linked external folders with no `index.md`

### Front-Matter Fields

| Field | Type | Purpose |
|-------|------|---------|
| `title` | string | Display name |
| `created` | ISO string | Creation timestamp |
| `modified` | ISO string | Auto-updated on every write |
| `tags` | string[] | Tagging / filtering |
| `icon` | string? | Emoji or icon code for sidebar |
| `order` | number? | Sort position among siblings |
| `dir` | `"rtl"` \| undefined | Text direction override |
| `google` | object? | Google Drive metadata for synced docs |

### RTL Auto-Detection

`writePage()` contains a heuristic that auto-sets `dir: "rtl"` when it detects that the document content is predominantly Hebrew (Unicode block U+0590–U+05FF). It samples the first 600 characters and sets RTL if Hebrew letters exceed 50% of all letter characters. This fires only when the caller does not explicitly set `dir`, so it never overrides a manual setting.

Sources: [src/lib/storage/page-io.ts:95-115](), [src/lib/storage/page-io.ts:29-75]()

### Page Lifecycle Operations

| Function | Action |
|----------|--------|
| `readPage(vPath)` | Read and parse a page; returns `PageData` with `content` and `frontmatter` |
| `writePage(vPath, content, fm)` | Serialize and overwrite; auto-updates `modified` |
| `createPage(vPath, title)` | Scaffold `index.md` with `defaultFrontmatter`; appends order at end of siblings |
| `deletePage(vPath)` | `rm -rf` or `unlink` for symlinks; removes `.cabinet-meta` from symlink targets |
| `movePage(from, toParent, opts)` | `fs.rename`; falls back to copy+delete on EXDEV (cross-device); updates order sidecar |
| `renamePage(vPath, newName)` | Renames directory, updates `title` in front-matter, rewrites wiki-link references across the whole cabinet |

---

## Virtual File Tree Builder (`tree-builder.ts`)

The sidebar and navigation are powered by `buildTree()`, which performs a recursive walk of `DATA_DIR` and returns a `TreeNode[]` tree.

### File Type Classification

`classifyFile(ext)` maps file extensions to `TreeNode["type"]` values. The recognized types are:

| Type | Extensions (representative) |
|------|----------------------------|
| `file` | `.md` (non-index) |
| `directory` | (bare directory) |
| `cabinet` | directory with `.cabinet` manifest |
| `code` | `.ts`, `.js`, `.py`, `.go`, `.rs`, … |
| `image` | `.png`, `.jpg`, `.svg`, `.webp`, … |
| `video` | `.mp4`, `.webm`, `.mov` |
| `audio` | `.mp3`, `.wav`, `.ogg` |
| `pdf` | `.pdf` |
| `csv` | `.csv` |
| `notebook` | `.ipynb` |
| `docx` / `xlsx` / `pptx` | Office formats |
| `mermaid` | `.mermaid`, `.mmd` |
| `website` / `app` | dirs with `index.html` |
| `unknown` | archives, design files, legacy Office |

Files whose extension does not match any known set are silently omitted from the tree.

### Directory Metadata Resolution

For a directory node, `buildTree` reads front-matter in this order:
1. `<dir>/index.md` frontmatter — preferred
2. `.cabinet-meta` YAML in a linked (symlinked) directory — fallback for externally linked folders

Cabinet directories are identified by the presence of a `.cabinet` manifest file (the `CABINET_MANIFEST_FILE` constant from `src/lib/cabinets/files.ts`).

### Ordering

Sibling ordering respects two storage locations:
- **`order` field in front-matter** — used for `.md`-backed pages
- **`.cabinet-order.yaml` sidecar** — used for non-Markdown files (images, code, PDFs) that cannot carry YAML front-matter

The tree sorts nodes by `order` ascending, then alphabetically by title as a tiebreaker.

### TTL Cache

Because `buildTree` walks potentially thousands of files on every request (sidebar, search, auto-link, composer), it is protected by a 5-second TTL cache (`createTtlCache`). After a user saves a page, the cache self-heals within those 5 seconds. Explicit invalidation is available via `invalidateTreeCache()`.

Sources: [src/lib/storage/tree-builder.ts:118-160](), [src/lib/storage/tree-builder.ts:221-232]()

---

## Low-Level Filesystem Operations (`fs-operations.ts`)

All I/O in the storage layer routes through a small set of functions in `fs-operations.ts`. These are thin wrappers around Node's `fs/promises` API.

| Function | Description |
|----------|-------------|
| `readFileContent(absPath)` | UTF-8 file read |
| `writeFileContent(absPath, content)` | UTF-8 file write |
| `writeFileAtomic(absPath, content)` | Crash-safe write via temp file + `rename()` |
| `deleteFileOrDir(absPath)` | `rm -rf` |
| `listDirectory(absPath)` | `readdir` with symlink resolution via `stat` |
| `unlinkSymlink(absPath)` | Remove `.cabinet-meta` from symlink target, then `unlink` the symlink |
| `ensureDirectory(absPath)` | `mkdir -p` |
| `fileExists(absPath)` | Boolean existence check via `access()` |

### Crash-Safe Writes

`writeFileAtomic` is used for metadata files whose corruption would silently break features. It writes content to a sibling temp file (`<path>.tmp-<pid>-<random>`), then calls `fs.rename()` to atomically move it over the target. A process crash between write and rename leaves either the old complete file or the new complete file on disk — never a half-written one.

Sources: [src/lib/storage/fs-operations.ts:16-34]()

---

## Sibling Ordering (`order-store.ts`)

Order values are managed by `order-store.ts`, which provides an abstraction layer that automatically picks the right storage location (front-matter vs. sidecar) for each entry type.

- The constant `ORDER_GAP = 100` is used as the default gap between adjacent sibling order values, leaving room for insertions without needing to renumber the full list.
- `computeInsertOrder(parentVirtualPath, prevName, nextName)` calculates the correct midpoint integer when inserting between two siblings. If no gap remains between them, it renumbers all siblings first.
- `appendOrder(parentVirtualPath)` returns `max(existing orders) + ORDER_GAP` for new entries appended at the end.

---

## Cabinet Discovery (`discovery.ts`)

A **cabinet** is any directory inside `DATA_DIR` that contains a `.cabinet` manifest file. Cabinet discovery is a recursive walk:

```ts
// src/lib/cabinets/discovery.ts
async function walkCabinets(dir: string, results: string[]): Promise<void> {
  for (const entry of entries) {
    const childDir = path.join(dir, entry.name);
    if (fs.existsSync(path.join(childDir, CABINET_MANIFEST_FILE))) {
      results.push(cabinetPathFromFs(childDir));
    }
    await walkCabinets(childDir, results);  // recurse into all dirs
  }
}
```

`discoverCabinetPaths()` is the async public API, protected by a 10-second TTL cache. It always includes `ROOT_CABINET_PATH` (the data root itself) as the first entry, so the root cabinet is always present even with no subdirectory cabinets. A sync variant `discoverCabinetPathsSync()` is available for startup paths where async I/O is not practical.

Hidden directories (dot-folders and the ignored set from `isHiddenEntry`) are skipped during the walk, so `.cabinet-state` and similar internal directories never appear as cabinets.

Sources: [src/lib/cabinets/discovery.ts:10-35](), [src/lib/cabinets/discovery.ts:55-65]()

---

## Git-Backed History (`git-service.ts`)

### Initialization

`getGit()` lazily initializes a `simple-git` instance pointed at `DATA_DIR`. On first call it checks whether `DATA_DIR/.git` exists. If it does not, it runs `git init` and configures a fixed committer identity (`Cabinet <kb@cabinet.dev>`). The result is cached in a module-level variable so subsequent calls avoid the filesystem check.

Sources: [src/lib/git/git-service.ts:7-22]()

### Auto-Commit with Debouncing

Every page save triggers `autoCommit(pagePath, action)`. To avoid spamming Git with one commit per keystroke, the function debounces via a 5-second timer. If another save arrives within the window, the previous timer is cleared and the window resets. When the timer fires, it stages all changes (`git add .`) and commits with the message `<action> <pagePath>` — for example `Update my-cabinet/notes/meeting`.

```ts
// src/lib/git/git-service.ts
export async function autoCommit(pagePath: string, action: "Update" | "Add" | "Delete") {
  if (commitTimer) clearTimeout(commitTimer);
  commitTimer = setTimeout(async () => {
    const g = await getGit();
    await g.add(".");
    const status = await g.status();
    if (status.staged.length === 0 && status.modified.length === 0) return;
    await g.commit(`${action} ${pagePath}`);
  }, 5000);
}
```

Sources: [src/lib/git/git-service.ts:27-42]()

### Page History and Diff Viewer

`getPageHistory(virtualPath)` queries `git log` for a specific file, trying three candidate paths (directory `index.md`, `.md` suffix, bare path) until one returns results. It returns up to 50 `GitLogEntry` objects, each carrying `hash`, `date`, `message`, and `author`.

`getDiff(hash)` returns the textual diff for a given commit using `git diff <hash>~1 <hash>`. For the first commit in the repository (no parent), it falls back to `git diff <hash>`.

### Point-in-Time Restore

`restoreFileFromCommit(hash, filePath)` performs a two-step restore:
1. `git checkout <hash> -- <filePath>` — reverts the working tree file to its state at that commit
2. `git add <filePath>` + `git commit` — records the restoration as a new commit, preserving history linearity

### Status and Uncommitted Changes

`getStatus()` surfaces uncommitted files to the UI's status bar. It filters out Cabinet-internal writes (`.cabinet-state/`, `.next/`, `node_modules/`, `.cabinet-cache/`) using a list of regex patterns, so runtime state changes never appear as pending user edits. The result is capped at 50 files; a `truncated` flag lets the UI show a "+N more" indicator.

Sources: [src/lib/git/git-service.ts:135-175]()

---

## Data Flow: Save to Committed History

The sequence below shows what happens from the moment a user saves a page to a stable Git commit:

```mermaid
sequenceDiagram
    participant UI as Browser UI
    participant API as Next.js API Route
    participant pageIO as page-io.ts
    participant fsOps as fs-operations.ts
    participant git as git-service.ts

    UI->>API: POST /api/pages/[path] (content + frontmatter)
    API->>pageIO: writePage(virtualPath, content, fm)
    pageIO->>pageIO: resolveContentPath() + path traversal check
    pageIO->>pageIO: inferDirFromText() → auto RTL?
    pageIO->>pageIO: matter.stringify(content, fm)
    pageIO->>fsOps: writeFileContent(absPath, output)
    fsOps-->>pageIO: written
    API->>git: autoCommit(pagePath, "Update")
    Note over git: 5-second debounce timer
    git->>git: g.add(".") + g.status()
    git->>git: g.commit("Update <pagePath>")
    git-->>API: committed
```

---

## Summary

Cabinet's file-based storage is deliberately simple: every page is a directory containing an `index.md` file, with YAML front-matter for structured metadata and Markdown for body content. Path safety is enforced at the boundary between virtual and absolute paths in `resolveContentPath`. The `buildTree` function converts the raw filesystem into a typed node tree for the sidebar, using a short TTL cache to keep performance acceptable. Git is initialized automatically in `DATA_DIR`, and every write is debounced into a commit within 5 seconds, providing a full, browseable change history for free with no additional infrastructure.

Sources: [src/lib/storage/path-utils.ts:15-19](), [src/lib/storage/tree-builder.ts:221-240](), [src/lib/git/git-service.ts:7-42]()

---

## 06. Next.js API Routes, Zustand Stores & UI Architecture

> The complete surface of Next.js API routes under src/app/api/ (agents, jobs, kb, git, search, terminal, onboarding, registry, and more), the Zustand client state stores (app-store, tree-store, ai-panel-store, editor-store, search-store), and the major React component groups (editor with Tiptap, sidebar, agents panel, composer, skills browser, settings). Closes the reference with extension points: adding a new provider adapter, registering a skill source, and what to inspect next.

- Page Markdown: https://grok-wiki.com/public/wiki/hilash-cabinet-73c70f449a59/pages/06-next.js-api-routes-zustand-stores-ui-architecture.md
- Generated: 2026-05-27T06:31:09.294Z

### Source Files

- `src/app/api/agents`
- `src/app/api/kb`
- `src/app/api/jobs`
- `src/stores/app-store.ts`
- `src/stores/tree-store.ts`
- `src/stores/ai-panel-store.ts`
- `src/components/editor`
- `src/components/agents`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [src/app/api/agents/route.ts](src/app/api/agents/route.ts)
- [src/app/api/agents/providers/route.ts](src/app/api/agents/providers/route.ts)
- [src/app/api/agents/conversations/route.ts](src/app/api/agents/conversations/route.ts)
- [src/app/api/agents/tasks/route.ts](src/app/api/agents/tasks/route.ts)
- [src/app/api/agents/skills/route.ts](src/app/api/agents/skills/route.ts)
- [src/app/api/agents/skills/catalog/route.ts](src/app/api/agents/skills/catalog/route.ts)
- [src/app/api/jobs/library/route.ts](src/app/api/jobs/library/route.ts)
- [src/app/api/registry/route.ts](src/app/api/registry/route.ts)
- [src/app/api/search/route.ts](src/app/api/search/route.ts)
- [src/stores/app-store.ts](src/stores/app-store.ts)
- [src/stores/tree-store.ts](src/stores/tree-store.ts)
- [src/stores/ai-panel-store.ts](src/stores/ai-panel-store.ts)
- [src/stores/editor-store.ts](src/stores/editor-store.ts)
- [src/stores/search-store.ts](src/stores/search-store.ts)
- [src/lib/agents/provider-interface.ts](src/lib/agents/provider-interface.ts)
- [src/lib/agents/provider-registry.ts](src/lib/agents/provider-registry.ts)
- [src/components/editor/editor.tsx](src/components/editor/editor.tsx)
- [src/components/editor/extensions.ts](src/components/editor/extensions.ts)
- [src/components/agents/agent-live-panel.tsx](src/components/agents/agent-live-panel.tsx)
- [src/components/sidebar/sidebar.tsx](src/components/sidebar/sidebar.tsx)
- [src/components/composer/composer-input.tsx](src/components/composer/composer-input.tsx)
- [src/components/skills/skill-catalog-browser.tsx](src/components/skills/skill-catalog-browser.tsx)
</details>

# Next.js API Routes, Zustand Stores & UI Architecture

Cabinet is a local-first knowledge-base and AI-agent workbench built on Next.js. The server is split between Next.js API routes (under `src/app/api/`) that handle all data mutation, agent orchestration, and external integrations, and a set of Zustand stores that hold every piece of client-visible state. The React component tree is organised by domain — editor, sidebar, agents, composer, skills, and settings — and the two layers communicate exclusively through store reads and fetch calls to the API routes. Understanding these three layers (API routes, stores, components) gives a maintainer complete mental ownership of the application.

---

## API Routes (`src/app/api/`)

The following table lists every subdirectory and its primary responsibility:

| Route namespace | Methods | Purpose |
|---|---|---|
| `/api/agents` | GET / POST | List active/recent sessions + stats; launch a new agent run |
| `/api/agents/[id]` | GET / DELETE | Individual session detail and cancel |
| `/api/agents/conversations` | GET / POST | List, filter, and create AI conversation records |
| `/api/agents/tasks` | GET / POST / PATCH | Task inbox CRUD with cabinet-scoped visibility |
| `/api/agents/providers` | GET / PUT | List all registered providers with health-check results; update default + disabled set |
| `/api/agents/providers/[id]/models` | GET | Dynamic model list for one provider (lazy, cached 60 s) |
| `/api/agents/skills` | GET / POST | List installed skills; create a new skill from frontmatter + body |
| `/api/agents/skills/catalog` | GET | Search mode (`?q=`) → proxies skills.sh; detail mode (`?owner=&repo=`) → GitHub meta + audit |
| `/api/agents/skills/[key]` | GET / PUT / DELETE | Read, update, or remove a single skill |
| `/api/agents/skills/scan` | POST | Re-scan disk for installed skills |
| `/api/agents/library` | GET | List saved agent persona templates |
| `/api/agents/events` | GET (SSE) | Server-sent events stream for live agent output |
| `/api/kb` | — | Namespace root |
| `/api/kb/pages/meta` | GET | Page metadata list (title, frontmatter) |
| `/api/jobs/library` | GET | Static job-template catalog from `JOB_LIBRARY_TEMPLATES` |
| `/api/registry` | GET | Agent registry templates from `getRegistryTemplates()` |
| `/api/search` | GET | Proxy to the local daemon's search service |
| `/api/git/commit` / `diff` / `log` / `pull` / `restore` | POST / GET | Git operations on the knowledge-base directory |
| `/api/terminal/open` | POST | Open / join a PTY session |
| `/api/onboarding` | GET / POST | First-run state and completion flags |
| `/api/upload/[path]` | POST | File-upload endpoint used by the editor's drag-and-drop handler |
| `/api/telemetry` | POST | Fire-and-forget usage event beacon |
| `/api/health` | GET | Liveness probe |

### Agents root — `/api/agents`

`GET` returns the union of `getActiveSessions()`, `getRecentSessions()`, and `getAgentStats()` from `agent-manager`. `POST` accepts `{ prompt, taskTitle, taskId, workdir, providerId }` and calls `runAgent(...)`, returning the new `sessionId`.

Sources: [src/app/api/agents/route.ts:1-50]()

### Providers — `/api/agents/providers`

The response is built by iterating `providerRegistry.listAll()`, running `p.healthCheck()` concurrently for every provider, and merging in `readProviderSettings()` and `getProviderUsage()`. The response is cached in module scope for 15 s to avoid spawning CLI probes on every page mount. The `dynamicModels: true` flag on a provider record signals that a second request to `GET /api/agents/providers/:id/models` is needed for the real model list.

Sources: [src/app/api/agents/providers/route.ts:20-100]()

### Search — `/api/search`

The route is a thin proxy: it authenticates via `getOrCreateDaemonToken()`, builds the daemon URL, and forwards the query with a 5 s `AbortSignal` timeout. All full-text indexing lives inside the daemon process; this route only adapts the HTTP interface.

Sources: [src/app/api/search/route.ts:1-58]()

### Skills catalog — `/api/agents/skills/catalog`

Operates in two modes selected by query parameters. Search mode (`?q=`) calls `https://skills.sh/api/search`, enriches results with per-skill audit summaries from `fetchAuditsBatch`, and caches the enriched list for 1 h. Detail mode (`?owner=&repo=`) fetches GitHub repo metadata plus a skill's `SKILL.md` frontmatter via the raw CDN. `GITHUB_TOKEN` is read from the environment to raise the GitHub API rate-limit ceiling.

Sources: [src/app/api/agents/skills/catalog/route.ts:1-80]()

---

## System Architecture

```text
Browser (React + Zustand)
┌──────────────────────────────────────────────────────────┐
│  app-store   tree-store   editor-store   ai-panel-store  │
│  search-store                                            │
│  ┌────────┐  ┌──────────┐  ┌──────────┐  ┌───────────┐ │
│  │Sidebar │  │  Editor  │  │  Agents  │  │ Composer  │ │
│  │(tree)  │  │ (Tiptap) │  │  Panel   │  │ (tasks)   │ │
│  └────┬───┘  └────┬─────┘  └────┬─────┘  └─────┬─────┘ │
└───────┼───────────┼─────────────┼───────────────┼────────┘
        │ fetch     │ fetch        │ fetch          │ fetch
┌───────▼───────────▼─────────────▼───────────────▼────────┐
│            Next.js API Routes  (src/app/api/)             │
│  /agents  /providers  /conversations  /tasks  /skills     │
│  /kb      /search     /git            /terminal           │
└────────────────────────────┬──────────────────────────────┘
                             │
              ┌──────────────▼──────────────┐
              │  lib/agents/  (server-side)  │
              │  agent-manager, provider-    │
              │  registry, conversation-     │
              │  store, skills/loader        │
              └──────────────┬──────────────┘
                             │ CLI spawn / API call
              ┌──────────────▼──────────────┐
              │  Provider CLIs / Daemon      │
              │  (claude-code, gemini-cli,   │
              │   codex-cli, opencode, …)    │
              └─────────────────────────────┘
```

---

## Zustand Client Stores (`src/stores/`)

All stores are created with `create<State>()` from Zustand with no middleware. Persistence is implemented by hand using `localStorage` or `sessionStorage` where noted.

### `useAppStore` — global UI layout

The largest store. Holds every cross-cutting layout concern.

| State field | Type | Persisted | Description |
|---|---|---|---|
| `section` | `SelectedSection` | No | Current primary view (home, cabinet, page, agents, task, settings, registry, help) |
| `returnTo` | `SelectedSection \| null` | No | Breadcrumb for `pushSection` / `popReturnTo` |
| `navHistory` / `navIndex` | `string[]` / `number` | No | Hash-based back/forward stack (cap 50) |
| `terminalOpen` | `boolean` | No | Terminal panel visibility |
| `terminalTabs` | `TerminalTab[]` | No | Open PTY tab list |
| `terminalPosition` | `"bottom" \| "right"` | `localStorage` | Terminal dock position |
| `sidebarCollapsed` | `boolean` | `localStorage` | Sidebar collapse state |
| `sidebarDrawer` | `"data" \| "agents" \| "tasks"` | `localStorage` | Active sidebar drawer tab |
| `aiPanelCollapsed` | `boolean` | No | Right-side AI panel visibility |
| `cabinetVisibilityModes` | `Record<string, CabinetVisibilityMode>` | `localStorage` | Per-cabinet page visibility depth (own, children-1, children-2, all) |
| `taskPanelOpen/Mode/ComposeContext` | — | No | Task side-panel state machine (compose ↔ conversation) |
| `taskRailOpen` | `boolean` | No | Slim recent-tasks rail on right edge |
| `providers` / `defaultProviderId` / `defaultModel` | — | No | Hydrated from `GET /api/agents/providers` |

Key actions:
- `loadProviders()` — one-shot deduped fetch; guards with `providersLoaded`.
- `ensureProviderModels(providerId)` — lazy per-provider model hydration with in-flight deduplication via `inflightModelFetches`.
- `goBack()` / `goForward()` — manipulate `window.location.hash` from the `navHistory` ring buffer.
- `setTerminalPosition("right")` — also collapses `aiPanelCollapsed` to avoid overlap.

Sources: [src/stores/app-store.ts:1-250]()

### `useTreeStore` — knowledge-base file tree

Manages the sidebar tree state including expand/collapse, drag-and-drop, and all CRUD operations.

| State | Notes |
|---|---|
| `nodes: TreeNode[]` | Loaded from `GET /api/tree`; stale-while-revalidate from `localStorage` (`kb-tree-cache`) |
| `selectedPath: string \| null` | Currently focused file path |
| `expandedPaths: Set<string>` | Persisted to `localStorage` (`kb-expanded-paths`) |
| `movingPaths: Set<string>` | Optimistic in-flight moves; used to dim the dragged row |
| `focusTick: number` | Bumped to signal sidebar scroll-to-selected |
| `showHiddenFiles: boolean` | Persisted to `localStorage` |

`renamePage` is the most complex action: it calls `renamePageApi`, reloads the tree, follows the `selectedPath` to the new path, and then checks the `editor-store`'s `currentPath` — if the editor has the renamed page open, it reloads it; if the editor has an open referrer that was rewritten and is not dirty, it reloads that too. A toast with an undo token is dispatched via `cabinet:toast` custom event.

Sources: [src/stores/tree-store.ts:1-290]()

### `useEditorStore` — single-page editor

Owns the lifecycle of the currently open Markdown page.

| State | Notes |
|---|---|
| `currentPath` | Path loaded or loading |
| `content` | Raw Markdown string |
| `frontmatter` | Parsed frontmatter object |
| `saveStatus` | `"idle" \| "saving" \| "saved" \| "error"` |
| `loadStatus` | `"idle" \| "loading" \| "ok" \| "missing" \| "error"` |
| `isDirty` | True when content has unsaved edits |
| `lastSavedAt` | Epoch ms for "Saved · Xs ago" status bar |

`loadPage` implements stale-while-revalidate: it immediately paints from a single-entry `localStorage` page cache before the fetch resolves, then replaces with the server response. Auto-save triggers 500 ms after `updateContent` via a debounced `setTimeout`. `loadStatus: "missing"` lets the editor distinguish a 404 (offer to create the page) from a 5xx error (show error).

Sources: [src/stores/editor-store.ts:1-200]()

### `useAIPanelStore` — AI side panel sessions

Tracks two session lists that survive same-tab page navigation:

- **`editorSessions: EditorSession[]`** — sessions tied to a specific `pagePath`, persisted to `sessionStorage` so the live stream can reconnect after a browser refresh.
- **`agentSessions: AgentLiveSession[]`** — free-running agent sessions not tied to a page, similarly persisted.

`addEditorSession` / `markSessionCompleted` / `removeSession` write through to `sessionStorage`. On mount, `restoreSessionsFromStorage()` and `restoreAgentSessionsFromStorage()` re-hydrate the lists and set `reconnect: true` on restored entries so the streaming consumer knows to rejoin an existing SSE stream rather than start a new one.

Sources: [src/stores/ai-panel-store.ts:1-185]()

### `useSearchStore` — command palette

Drives the global search palette (`Cmd+K`). Fields:

| Field | Notes |
|---|---|
| `open` | Palette visibility |
| `query` | Current input string |
| `scope: SearchScope` | `"all" \| "pages" \| "agents" \| "tasks"` |
| `results: SearchResponse \| null` | Last daemon response |
| `recentQueries / recentPageIds` | Persisted (up to 8 each) in `localStorage` |
| `aiPending / aiResult` | AI-assisted query state |

`commitRecentQuery` and `commitRecentPage` deduplicate and cap their lists before writing to `localStorage`. The `setResults` action auto-selects the first result across all hit types.

Sources: [src/stores/search-store.ts:1-130]()

---

## React Component Groups

### Editor (`src/components/editor/`)

The editor is a Tiptap ProseMirror instance mounted in `editor.tsx`. The extension bundle registered in `extensions.ts` is curated for minimal bundle size — 13 highlight.js languages (bash, css, go, JavaScript, JSON, Markdown, Python, Rust, shell, SQL, TypeScript, XML, YAML) instead of the full 35+ `common` set.

**Custom extensions registered:**

| Extension | File | Purpose |
|---|---|---|
| `WikiLink` | `wiki-link-extension.ts` | `[[Page Name]]` cross-links with in-editor navigation |
| `CalloutExtension` | `callout-extension.ts` | Styled callout blocks |
| `ResizableImage` | `extensions/resizable-image` | Drag-to-resize embedded images |
| `EmbedExtension` | `extensions/embed-extension` | URL embed cards (video, tweet, etc.) |
| `CabinetMath` | `extensions/math-extension` | LaTeX math rendering |
| `DragHandle` | `extensions/drag-handle` | Block-level drag-and-drop |
| `IconExtension` | `extensions/icon-extension` | Inline icon picker |
| `HeadingAnchors` | `extensions/heading-anchors` | Auto-generated heading IDs |
| `AutoDirection` | `extensions/auto-direction` | RTL/LTR auto-detection per-block |
| `FindExtension` | `extensions/find` | In-editor find-and-highlight |
| `EditorMentionExtension` | `mention-extension.ts` | `@mention` chips for agent/page refs |
| `colorAndStyleExtensions` | `extensions/color-highlight` | Text color and highlight marks |

File upload (drag-and-drop or paste) is handled inline in `editor.tsx`; files are POST-ed to `/api/upload/:pagePath` and the returned URL is inserted as a Markdown image node.

Sources: [src/components/editor/extensions.ts:1-130](), [src/components/editor/editor.tsx:1-80]()

### Sidebar (`src/components/sidebar/`)

`sidebar.tsx` is a resizable panel (220–420 px, default 280 px) with pointer-drag resize logic. It reads `useAppStore.sidebarCollapsed`, `useAppStore.sidebarDrawer`, and `useTreeStore`. The drawer has three tabs — `data` (the tree view), `agents`, and `tasks` — controlled by `setSidebarDrawer`. The `TreeView` + `TreeNode` sub-components handle expand/collapse, context menus, drag-and-drop zones, and inline rename.

Sources: [src/components/sidebar/sidebar.tsx:1-60](), [src/stores/app-store.ts:54-60]()

### Agents Panel (`src/components/agents/`)

| Component | Purpose |
|---|---|
| `agent-list.tsx` | Gallery of all configured agents with filter tabs |
| `agent-detail-v2.tsx` | Full agent detail with composer, conversation list, and live session indicator |
| `agent-live-panel.tsx` | Inline terminal + heartbeat history for a running agent session |
| `conversation-live-view.tsx` | SSE-backed live stream view |
| `conversation-result-view.tsx` | Completed conversation viewer |
| `pending-actions-panel.tsx` | Human-approval queue for agent-proposed actions |
| `heartbeat-dialog.tsx` | Schedule a recurring heartbeat run |
| `new-routine-dialog.tsx` | Create a new scheduled routine |
| `provider-glyph.tsx` | Per-provider icon badge |

`agent-live-panel.tsx` reads `agentSessions` from `useAIPanelStore` and renders a `WebTerminal` component for each live session, including reconnect support when `session.reconnect === true`.

Sources: [src/components/agents/agent-live-panel.tsx:1-60]()

### Composer (`src/components/composer/`)

The composer is a reusable prompt-input surface used across the agents panel, task creation dialogs, and start-work flows. `ComposerInput` (`composer-input.tsx`) accepts a `UseComposerReturn` hook, `MentionableItem[]` for the `@mention` dropdown, optional `attachments`, and a `focusTint` for agent brand coloring. Structural props include `topRightOverlay` (for scheduling chips), `header`, `footer`, `actionsStart`, and a `secondaryAction` slot. `AttachmentChips` and `MentionChips` render selected context items; `MentionDropdown` is the suggestion list.

Sources: [src/components/composer/composer-input.tsx:1-60]()

### Skills Browser (`src/components/skills/`)

| Component | Purpose |
|---|---|
| `skill-catalog-browser.tsx` | Search-first browse of skills.sh; debounced 300 ms input, minimum 2 chars; each row shows install count + audit-pass ratio |
| `skill-library.tsx` | List of installed skills with usage stats |
| `skill-detail.tsx` | Individual skill view: description, allowed tools, source provenance |
| `skill-add-dialog.tsx` | Install-from-source dialog; wraps the catalog browser |
| `skill-picker.tsx` | Inline multi-select used in agent-compose forms |

`skill-catalog-browser` calls `GET /api/agents/skills/catalog?q=<query>` and passes the selected skill's `source` field to the parent via `onPick`, which then triggers preview and install.

Sources: [src/components/skills/skill-catalog-browser.tsx:1-60]()

### Settings (`src/components/settings/`)

`settings-page.tsx` composes: `api-keys-section` (per-provider key management), `built-in-tools-section` (toggle MCP tools), `cli-mcp-section` (CLI MCP server config), `integrations-hub-section` (Slack, GitHub, etc.), `data-locations-section` (cabinet root and output paths), `storage-backend-section`, and `uninstall-section`. Provider settings are written back via `PUT /api/agents/providers`.

---

## Provider Adapter System

The provider layer is the primary extension surface for adding new AI backends.

### `AgentProvider` interface

Defined in `src/lib/agents/provider-interface.ts`, the interface declares:

```ts
interface AgentProvider {
  id: string;                   // unique key
  name: string;
  type: "cli" | "api";
  isAvailable(): Promise<boolean>;
  healthCheck(): Promise<ProviderStatus>;

  // CLI providers
  buildOneShotInvocation?(prompt, workdir, opts?): CliProviderInvocation;
  buildSessionInvocation?(prompt, workdir, opts?): CliProviderInvocation;
  supportsTerminalResume?: boolean;

  // Dynamic model discovery
  listModels?(): Promise<ProviderModel[]>;

  // API providers
  runPrompt?(prompt, context): Promise<string>;
}
```

Sources: [src/lib/agents/provider-interface.ts:56-120]()

### Registered built-in providers

| Provider id | File | Type |
|---|---|---|
| `claude-code` | `providers/claude-code.ts` | CLI |
| `codex-cli` | `providers/codex-cli.ts` | CLI |
| `gemini-cli` | `providers/gemini-cli.ts` | CLI |
| `cursor-cli` | `providers/cursor-cli.ts` | CLI |
| `opencode` | `providers/opencode.ts` | CLI |
| `grok-cli` | `providers/grok-cli.ts` | CLI |
| `copilot-cli` | `providers/copilot-cli.ts` | CLI |
| `pi` | `providers/pi.ts` | CLI |

The singleton `providerRegistry` (module-level `ProviderRegistryImpl`) is the only write surface. Registering a new provider requires a single `providerRegistry.register(myProvider)` call; the registry's `listAll()` is the source for `GET /api/agents/providers`.

Sources: [src/lib/agents/provider-registry.ts:1-76]()

### Adding a new provider adapter

1. Create `src/lib/agents/providers/my-provider.ts` that implements `AgentProvider`.
2. Implement at minimum `isAvailable()`, `healthCheck()`, and `buildOneShotInvocation()`.
3. Add `providerRegistry.register(myProvider)` at the bottom of `src/lib/agents/provider-registry.ts`.
4. The provider will appear in `GET /api/agents/providers` responses and in the Settings → Providers UI automatically — no other wiring is required.
5. Set `dynamicModels: true` (i.e. implement `listModels()`) if the backend can report its available models at runtime; `useAppStore.ensureProviderModels()` will call the models endpoint lazily when the provider's tab opens.

---

## Skill Sources and Extension Points

Skills are file-system-based `SKILL.md` bundles. The skill loader (`src/lib/agents/skills/loader.ts`) resolves three origin classes:

| Origin | Description |
|---|---|
| `system` | Built-in skills shipped with Cabinet |
| `linked` | Symlinked from external repositories or packs |
| `legacy` | Skills present in the old flat `~/.cabinet/skills/` directory |

`GET /api/agents/skills` accepts an `?origins=system,linked,legacy` filter and returns all three by default. To register a new skill source:

1. Write a `SKILL.md` file in a directory named after the skill key (kebab-case).
2. POST to `POST /api/agents/skills` with `{ key, name, description, body, scope }` — scope can be `"root"` (global) or `"cabinet:<path>"` (cabinet-scoped). The route creates the directory and file, then calls `readSkill` to return the hydrated record.
3. For package-distributed skills, create a symlink in the linked-skills root so the loader picks them up as origin `"linked"`.

Sources: [src/app/api/agents/skills/route.ts:1-80]()

---

## Data Flow Summary

```text
User action (e.g. open page)
  │
  ▼
Zustand store action (tree-store.selectPage / editor-store.loadPage)
  │
  ├─► localStorage cache → immediate paint (stale content)
  │
  └─► fetch("/api/kb/...") or fetch("/api/tree")
          │
          ▼
      Next.js route handler (Node.js / server)
          │
          ├─► lib/agents/* (agent-manager, persona-manager, etc.)
          │       └─► provider CLI spawn / API call
          │
          └─► File system (markdown pages, SKILL.md, settings JSON)
```

The stores are the single source of truth for UI state; the API routes are the single write path to disk and external services. Components never access the file system or provider CLIs directly — they read from a store or call an API route.

---

## What to Inspect Next

- **`src/lib/agents/conversation-runner.ts`** — how a `POST /api/agents/conversations` turns a user prompt into a streamed PTY session, including system-prompt construction and the editor/manual prompt builders.
- **`src/lib/agents/adapters/`** — the adapter layer between the generic `AgentProvider` interface and the actual PTY launch for each CLI; `agentAdapterRegistry` parallels `providerRegistry`.
- **`server/search/`** — the daemon process that owns the full-text index; the Next.js search route is only a proxy into this process.
- **`src/app/api/agents/events/route.ts`** — the SSE endpoint that drives live agent output in `conversation-live-view.tsx` and `agent-live-panel.tsx`.
- **`src/lib/agents/skills/`** — `loader.ts`, `stats.ts`, `lock.ts`, and `upstream.ts` show the complete skill lifecycle from disk scan through install-count decoration.

Cabinet's architecture cleanly separates BYOC/BYOK concerns: the `AgentProvider` interface is provider-neutral, skill sources are portable file-system paths with no vendor lock-in, and the search daemon is a local process with no cloud dependency. Adding a model or skill source requires implementing one interface or creating one file directory — no framework internals need to change.

---
