SLOPSHOPPER

ai-maestro-sql-approval

Shows a database change an agent proposed with safe-psql-write (what changes, which rows, which environment) and lets you approve it by typing the environment…

newpanebandcommandtoasttimer
★ 822v0.1.0MITupdated 2026-10-1423blocks-OS/ai-maestro/mods/ai-maestro-sql-approval
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ai-maestro-sql-approval
│ ┃ sql-approval ✕ › fix the failing auth test and add an audit log call │ ┃ No database change is waiting. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sql-approval │ ⎿ ai-maestro-sql-approval: No database change is waiting for you. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sql-approval
No database change is waiting.
README

<img src="./docs/logo-constellation.svg" alt="AI Maestro Logo" width="120"/>

AI Maestro

I was running 35 AI agents across multiple terminals and became the human mailman between them. So I built AI Maestro.

The OS for AI-first organizations — orchestrate any AI agent with persistent memory, agent-to-agent messaging, and multi-machine support.

Version Platform-lightgrey) License GitHub Stars

AI Maestro Dashboard

Quick Start · Features · Skills · Protocols · Documentation · Contributing


The Story

I gave an AI agent a real task — not autocomplete, a real engineering problem. It checked the code, read the logs, queried the database, and came back with the answer. That was the moment. This thing can actually work.

Within a week I was running 35 agents across terminals. They were productive, but they couldn't talk to each other. I became the human message bus — copying context from one terminal, pasting into another. I was the bottleneck in my own AI team.

So I built AI Maestro — one dashboard to see every agent, on every machine, with persistent memory and direct agent-to-agent communication. Today I run 80+ agents across multiple computers, building real companies with them every day.

What makes this different:

  • Works with the agents you already use — Claude Code, Codex and Grok Build get launch, chat history, status and approval cards. Pi, OpenClaw and GitHub Copilot CLI launch from AI Maestro and run in the terminal tab, as does any other terminal agent. What each one supports. We don't lock you in.
  • Multi-machine from day one — Peer mesh network with no central server. Nobody else does this.
  • Agents that communicate — The Agent Messaging Protocol (AMP) lets agents coordinate directly. You orchestrate, they collaborate.
  • Yours, entirely — MIT licensed. No account, no telemetry, no per-seat pricing, no hosted tier we upsell you to. Your code and your agents stay on your machines.

The Mental Model

AI Maestro is an operating system for an AI-first company. Not a task runner — a place where a standing team works.

An agent here is closer to an employee than to a job. It has a name, a face, a memory that survives the session, an inbox — and it owns something: a product, a repository, a process, a customer. Its memory and code graph are indexed against what it owns, which is why it gets better at that thing over months rather than starting cold every morning.

Because agents own things, they don't share a working copy. Two agents that need the same repository each clone it, work on their own branch, and integrate through git — push, pull request, review, merge — exactly like two engineers on a team.

That's deliberate, and it follows from the one thing that defines this product: your agents run on different machines. A shared checkout needs a shared filesystem. Git worktrees — the isolation primitive the single-machine agent IDEs are built on — are several working directories over one .git store on one disk, so they stop working the moment your backend agent is on a Linux box and your iOS agent is on a Mac. A clone is the only primitive that survives the move. It's why transferring an agent to another host clones its repos to the destination: an agent's repositories travel with the agent.

The full model →


Quick Start

curl -fsSL https://raw.githubusercontent.com/23blocks-OS/ai-maestro/main/scripts/remote-install.sh | sh

This installs everything you need:

  • AI Maestro dashboard and service
  • Agent messaging system (AMP)
  • The AI Maestro plugin for Claude Code: 13 skills and their CLI scripts, plus the aim-secret and safe-psql helpers

Time: 5-10 minutes · Requires: Node.js 18+, tmux · Optional: Claude Code 2.1.287+ for the inbox mod

Windows: Install WSL2 first (PowerShell as Administrator, then restart), then open Ubuntu from the Start menu and run the curl command there:

wsl --install

New to WSL? WSL is a Linux system with its own disk, so its home folder starts empty. Keep agents there (~/agents/<name>, the default), not in C:\ folders, which are much slower from WSL. Open their files from Windows at \\wsl.localhost\Ubuntu\home\<you>\agents. Windows in 5 minutes · Full Windows guide

Linux: Ensure build tools are installed: sudo apt install tmux build-essential

git clone https://github.com/23blocks-OS/ai-maestro.git
cd ai-maestro
yarn install
yarn dev

See QUICKSTART.md for detailed setup options.

Dashboard opens at http://localhost:23000

Update an existing install from its folder (pulls, updates submodules, builds, reinstalls hooks and the plugin, restarts):

./update-aimaestro.sh -y

Come back to it without remembering the address: the installer adds an ai-maestro command (ai-maestro open starts the server if it is down, then opens the dashboard; also start, stop, status) and, on a Mac, an AI Maestro app in ~/Applications you can drag to the Dock. It can also start at login. Chrome and Edge offer "Install app" from the dashboard too. Skip all of it with --skip-launcher; remove it with ./scripts/install-launcher.sh --remove.


Features

Every feature was born from running a real AI-first organization. We built them in the order we needed them.

One Dashboard

I had 35 terminals and couldn't tell which was which.

See and manage all your AI agents in one place. Create agents from the UI with a guided wizard, organize them with smart naming (project-backend-api becomes a 3-level tree with auto-coloring), and switch between any agent with a click. Four deployment modes: tmux (local), Docker (containerized), AWS EC2 (dedicated instance), and AWS ECS Fargate (serverless). Auto-discovers tmux sessions, Docker containers, cloud deployments, and standalone agents.

Any Machine

My Mac Mini was sitting there idle. What if I ran agents on that too?

A peer mesh network where every machine is equal. Add a computer, it joins the mesh. Every agent on every machine, visible from one dashboard. Use each machine for what it's best at — Mac for iOS builds, Linux for Docker, cloud for heavy compute. No central server required.

Worker machines can run headless (yarn headless) — the full API and agent runtime with no UI, in about 100MB of RAM. Run the dashboard where you sit; run agents wherever the compute is.

Agent Messaging

I was the mailman — copying messages between agents because they couldn't talk to each other.

The Agent Messaging Protocol (AMP) gives your agents email-like communication. Priority levels, message types, cryptographic signatures, and push notifications. Tell your agent "send a message to backend about the deployment" — it just works. Agents coordinate directly while you manage the big picture.

Before AMP: You copy research from one terminal, paste into another, repeat 50 times a day. With AMP: "Research agent, send your findings to the writing agent." Done.

Gateways

A friend in Singapore wanted his agents to talk to mine. But I didn't want to give him access to my network.

Connect your AI agents to Slack, Discord, Email, and WhatsApp through organizational gateways. Smart routing (@AIM:agent-name), thread-aware responses, and content security with 34 prompt injection patterns detected at the gateway — before any agent sees the message.

Persistent Memory

Every morning, my agents woke up with amnesia.

Long-term memory is a skill you switch on per agent. Every night each agent's conversations become memory: short statements of what it learned, each backed by the passages it came from, and an entity graph of the things it works with and how they relate (runs on, depends on, stores data in, deploys to), kept current, with ended relations marked. Claude Code deletes transcripts after 30 days; AI Maestro rebuilds that history from the agent's own message index, so months of work become memory instead of disappearing. When a prompt names something, the agent is told what it relates to before it acts, so it can see what a change affects. Knowledge that comes up in more sessions weighs more; secrets are redacted and never stored. How it works →

Alongside it: Code Graph (interactive visualization of your entire codebase with delta indexing) and Documentation (auto-generated, searchable docs from your code). Agents get smarter the longer they work with you.

Chat

I wanted to answer an agent's question without finding its terminal.

Talk to an agent in a chat view, on desktop or on your phone. When the agent asks a question or needs an approval, it shows up as a card you can click, and the click sends the same key you would press in the terminal. Claude Code, Codex and Grok Build all work here. How the chat works →

Secrets

I kept pasting API keys into chats, and the model kept seeing them.

Agents use a credential without seeing it. aim-secret keeps values in a local vault (macOS Keychain, Linux libsecret, or an encrypted file). An agent asks for a secret by name; a card in the chat, with a masked field, lets you store it; the agent then runs commands that use it by name, with the value scrubbed from the output. In Claude Code, the ai-maestro-secrets mod asks through a form in the terminal instead. Scrubbing stops accidents, not an agent that wants the value, so use narrowly scoped keys. Secrets → · Claude Code mods →

Work Coordination

Talking isn't working. I needed agents to coordinate on actual deliverables.

Assemble agents into teams, run meetings in split-pane war rooms, and track tasks on a full Kanban board with drag-and-drop, dependencies, and 5 status columns. Cross-machine teams work seamlessly. This is project management for your AI workforce.

Claude Code Mods

A Claude Code session with no tmux has nowhere for a message to land.

Mods are plugins that run inside Claude Code 2.1.287 or later. AI Maestro ships two: an inbox mod that shows unread agent messages in the status line and above the prompt, and a secrets mod that lets an agent request a credential. AI Maestro itself works with any current Claude Code version. Details and limits →

Canvas

Sometimes the answer is a form, a chart or an approval, not a paragraph.

Agents draw interactive HTML pages in the dashboard, and what you click or submit comes back to them as a structured action (AAP). Agent actions →

Scheduled Work

Some jobs shouldn't wait for me to remember to ask.

Give an agent its own schedule — morning triage, a nightly dependency check, a Monday report. The timer belongs to the agent, not the machine, so a schedule travels with the agent when it moves hosts, and fires when that agent goes idle instead of interrupting it mid-task.

Agent Presence

At 80 agents, they all looked the same.

Custom avatars, personality profiles, and roles for every agent. When an agent has a face and a job title, you instinctively assign it the right work — just like a real team.

Agents can also speak and be seen: a voice pipeline with your choice of TTS provider, and live animated faces that move while the agent talks. Open a call with an agent and it looks back at you. Nobody else is doing this, and once you've reviewed a plan by listening to it on a walk, the terminal feels like a downgrade.

One caveat worth stating plainly: true lip-sync — mouth movement driven by the actual audio — needs the OpenAI or ElevenLabs voice and an API key. The default browser voice (web-speech) renders straight to your speakers and exposes no audio stream to measure, so the face animates from a synthetic envelope instead. It looks alive; it is not tracking the words. That is a limit of the Web Speech API, not something we plan to work around.

Agent Deployment

One agent on my laptop. Another on EC2. A third on Fargate. All in the same dashboard.

Four ways to run agents, each for a different need:

ModeWhatBest For
tmuxDirect terminal sessions on your machineLocal development, zero setup
DockerContainerized agents with resource limitsIsolation, reproducibility, multi-project
AWS EC2Dedicated Graviton instance with native install, Nginx + SSLAlways-on agents, SSH access, persistent workloads
AWS ECS FargateServerless containers, auto-built Docker imageBurst scaling, zero maintenance, pay-per-use

Cloud agents are Terraform-managed. EC2 installs Node.js, tmux, and AI CLIs directly on ARM64 hardware (no Docker overhead). ECS auto-builds your Docker image, pushes to ECR, and runs on Fargate. Both deploy with one command from the dashboard or CLI.

# EC2: dedicated instance with SSL
aimaestro-agent.sh create my-api --ec2 \
  --domain api.example.com --ssl-email admin@example.com --key-name my-key

# ECS Fargate: serverless (auto-builds image)
aimaestro-agent.sh create worker --ecs

Meet Lola — Your First Hire

Every AI-first organization starts with one agent. Lola is yours — a batteries-included Chief of Staff framework built for AI Maestro. She handles email triage, semantic memory, task management, and content security out of the box. Install the platform, deploy Lola, and you have your first employee on day one.

# Clone and deploy Lola on AI Maestro
git clone https://github.com/23blocks-OS/lolabot.git
cd lolabot && ./setup.sh

The LolaBot Ecosystem:

  • LolaBot Framework — Open-source agent framework. Build your own Lola.
  • lolabots.com — The LolaBot Factory. Pre-built agent templates, one-click deploy.

Learn more about Lola →


The Agent Ecosystem

Every agent is built from five dimensions:

| | Dimension | What | Where | |-|-----------|------|-------| | WHO | Personality | Domain expertise, workflows, deliverables | Agent Library (150+) | | HOW | Capabilities | Skills, scripts, CLI tools, Canvas | Plugin Builder | | TRUST | Identity | Cryptographic keys, OAuth tokens | AID | | TALK | Communication | Agent-to-agent messaging | AMP | | ACT | Actions | Clicks and forms from agent-drawn pages | AAP |

See Skills and Protocols below.

AI Maestro is the stage. Pick personalities, give them skills, and run them from one dashboard.

Read the full ecosystem guide →


Skills

The AI Maestro plugin (version 1.7.3) carries 13 skills in the open Agent Skills Standard format. An agent loads a skill when its description matches the task.

SkillWhat it doesSource
memory-searchSearch what the agent learned in past sessions, and what depends on an entityplugin builder
docs-searchLook up signatures and APIs in the generated docsplugin builder
graph-queryCallers, dependencies and blast radius from the code graphplugin builder
ai-maestro-agents-managementCreate, rename, hibernate, wake and delete agentsplugin builder
planningPersistent plan, findings and progress files for long tasksplugin builder
aim-secret-managementUse a credential without seeing it; ask the user to store oneplugin builder
agent-messagingSigned messages between agents (AMP)agentmessaging/claude-plugin
agent-identitySign in with an Ed25519 identity and OAuth 2.0 tokens (AID)agentmessaging/agent-identity
canvas-actionsInteractive pages in the dashboard and their actions (AAP)agentmessaging/agent-actions
agent-filesShare files across hosts through an AFP spaceagentmessaging/agent-files
agentic-sqlRead-only SQL against real databases through safe-psql; changes data only through safe-psql-write, after a person approves each change23blocks-OS/agentic-sql
agentic-supportNumbered support cases with a written trail, and runbooks23blocks-OS/agentic-support
agentic-seoSEO audit and implementation for websites23blocks-OS/agentic-seo

The installer and updater set up the helpers the skills need (aim-secret, safe-psql). To add only the plugin to Claude Code:

/plugin marketplace add 23blocks-OS/ai-maestro-plugins
/plugin install ai-maestro@ai-maestro-plugins

Pick your own set with the plugin builder. More on the skills page; how we keep them good is in SKILLS-QUALITY.md.


Protocols

AI Maestro implements four open protocols, all under github.com/agentmessaging. Each works without the others.

ProtocolCoversSpecification
AMP — Agent Messaging ProtocolSigned messages between agents, local or federatedagentmessaging.org
AID — Agent IdentityEd25519 agent identity and OAuth 2.0 tokensagentids.org
AAP — Agent Actions ProtocolActions from agent-drawn pages back to the agentagentactions.org
AFP — Agent Files ProtocolFiles shared by reference through S3-compatible storageagentmessaging/agent-files

Protocols page · AID, AMP and AAP mapped onto the Poppy draft (a design note, nothing implemented) · AMP outbox design (a proposal, not built)


Who Is This For

Founders building AI-first organizations. You're the CEO, your agents are the team. Solo operators, agency owners, and anyone running a business where AI agents do the work and you drive the strategy. Start with Lola, add specialists, scale your AI workforce.

Developers running multiple AI agents. If you have 3+ agents and you're switching between terminals, losing context, and playing messenger — this is for you. Works with Claude Code, Codex, Grok Build, or any terminal-based AI.

Teams coordinating AI-assisted work. Multiple developers, multiple agents, multiple machines. One dashboard. Agent-to-agent messaging replaces you as the bottleneck.

Creators and operators who want to connect AI agents to the outside world through Slack, Discord, or Email — without exposing their infrastructure.


<img src="./docs/images/aimaestro-mobile.png" alt="Mobile View" width="280"/> <img src="./docs/images/aimaestro-sidebar.png" alt="Sidebar" width="280"/>

Code Graph — Interactive codebase visualization

Code Graph

Agent Inbox — Direct agent-to-agent messaging

Agent Messaging


Documentation

New here?

Going deeper:

Troubleshooting:

Open Protocols:

Extending:


What's Next

  • Create an agent from a repo URL — clone and staff it in one step, instead of cloning by hand and pointing an agent at the folder
  • Agent search and filtering across the entire mesh
  • Agent playback — time-travel through agent sessions
  • Performance analytics dashboard

See the full roadmap.


Contributing

We love contributions. See CONTRIBUTING.md for guidelines.

Contributors

AI Maestro is better because of people outside the team who hit something, read the source, and sent a fix. In order of arrival:

  • @Emasoft — a consolidated fix across memory, terminal, installers, skills and the API covering 65+ issues, plus CI repairs for the installer workflow and the agent-cli skill check. The single largest outside contribution to this project.
  • **[@dwzrd86](https://gi
Source 2 files
hooks/register.tsx 304 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4// F037. An agent proposes a database change with `safe-psql-write propose`; the wrapper writes a
5// request file under $SAFE_PSQL_HOME/requests (default ~/.safe-psql/requests). This mod shows the
6// requests that belong to THIS agent and does the same state change the wrapper's `approve` and
7// `reject` do (the wrapper's own approve refuses to run inside an agent session, and needs a
8// terminal). It never connects to the database and never runs the wrapper: the agent still has to
9// run `safe-psql-write apply`, which repeats every check, including the fingerprint.
10//
11// Honest limit: the agent and the person share one OS user, so this stops accidents and the
12// sanctioned path, not an agent that edits the request file itself. The write login's own limits
13// are the real backstop. See docs/F037-agentic-sql-card-handoff.md.
14
15const POLL_MS = 10_000
16const ID_RE = /^w-[0-9a-f]{12}$/
17const PANE = 'sql-approval'
18const PANE_ARGS = { id: PANE, focus: true, closeOnEscape: true, rows: 22 } as const
19const FG_DARK_THEME = '#ffffff'
20const FG_LIGHT_THEME = '#1a1a1a'
21
22type Req = {
23  id: string; status: string; expires: number; ttl?: number; agent: string; environment: string; table: string
24  kind: string; sql: string; rows: number; max_rows?: number; reason?: string; fingerprint: string
25  params: Record<string, unknown> | null; diff: Array<Record<string, any>> | null; diff_truncated?: boolean
26  has_pk?: boolean; param_names?: string[]; [k: string]: unknown
27}
28
29// the request the band is about; changing it redraws the band
30const waiting = atom({ plugin: 'ai-maestro-sql-approval', key: 'waiting' } as const, null)
31let current: Req | null = null
32let typed = ''
33
34// The wrapper's clean(): no control, separator or escape characters, bounded length.
35export function clean(text: unknown, limit = 300): string {
36  const chars = Array.from(String(text), c => (c === ' ' || !/[\p{C}\p{Z}]/u.test(c) ? c : '?'))
37  return chars.length <= limit ? chars.join('') : chars.slice(0, limit).join('') + '...'
38}
39
40const repr = (v: unknown) => (v === undefined ? 'null' : JSON.stringify(v))
41
42export function describeDiff(r: Req): string[] {
43  const out: string[] = []
44  for (const d of Array.isArray(r.diff) ? r.diff : []) {
45    const key = d?.key ? ` (key ${clean(JSON.stringify(d.key))})` : ''
46    if (d?.changes && typeof d.changes === 'object') {
47      const t = Object.entries(d.changes as Record<string, any>).map(([c, v]) => `${c}: ${repr(v?.[0])} -> ${repr(v?.[1])}`).join('; ')
48      out.push(`row${key}: ${clean(t, 400)}`)
49    } else if (d?.deleted) out.push(`DELETE row${key}: ${clean(JSON.stringify(d.deleted), 400)}`)
50    else out.push(`INSERT row${key}: ${clean(JSON.stringify(d?.new ?? {}), 400)}`)
51  }
52  if (r.diff_truncated) out.push(`... and ${Math.max(0, Math.trunc(r.rows) - out.length)} more`)
53  if (r.has_pk === false) out.push('(the table has no primary key, so rows are not matched before/after)')
54  return out
55}
56
57// Everything the logic needs from the engine, so tests can hand it a world from memory.
58export type IO = {
59  env: (name: 'HOME' | 'USER' | 'SAFE_PSQL_HOME' | 'AIM_AGENT_NAME' | 'AIM_AGENT_ID') => Promise<string | undefined>
60  now: () => Promise<number>
61  read: (path: string) => Promise<string>
62  write: (path: string, text: string) => Promise<void>
63  exists: (path: string) => Promise<boolean>
64  list: (path: string) => Promise<Array<{ name: string; kind: string; size: number; isLink: boolean }>>
65}
66
67const ioOf = ($: Engine): IO => ({
68  env: async name => {
69    switch (name) {
70      case 'HOME': return (await $.env.get('HOME')) ?? undefined
71      case 'USER': return (await $.env.get('USER')) ?? undefined
72      case 'SAFE_PSQL_HOME': return (await $.env.get('SAFE_PSQL_HOME')) ?? undefined
73      case 'AIM_AGENT_NAME': return (await $.env.get('AIM_AGENT_NAME')) ?? undefined
74      default: return (await $.env.get('AIM_AGENT_ID')) ?? undefined
75    }
76  },
77  now: async () => await $.clock.now(),
78  read: async path => String(await $.fs.read(path)),
79  write: async (path, text) => { await $.fs.write(path, text) },
80  exists: async path => await $.fs.exists(path),
81  list: async path => (await $.fs.list(path)) as any,
82})
83
84// Same identity rule as the wrapper: AIM_AGENT_NAME, else AIM_AGENT_ID, else $USER.
85async function identity(io: IO): Promise<string[]> {
86  const ids: string[] = []
87  const byName = await io.env('AIM_AGENT_NAME')
88  const byId = await io.env('AIM_AGENT_ID')
89  if (byName) ids.push(byName)
90  if (byId) ids.push(byId)
91  if (ids.length === 0) {
92    const u = await io.env('USER')
93    if (u) ids.push(u)
94  }
95  return ids
96}
97
98async function requestsDir(io: IO): Promise<string> {
99  const home = await io.env('SAFE_PSQL_HOME')
100  if (home) return `${home}/requests`
101  return `${(await io.env('HOME')) ?? ''}/.safe-psql/requests`
102}
103
104function parse(text: unknown, id: string): Req | null {
105  try {
106    const r = JSON.parse(String(text))
107    const ok = r && typeof r === 'object' && r.id === id && typeof r.status === 'string' && typeof r.expires === 'number'
108      && typeof r.environment === 'string' && typeof r.table === 'string' && typeof r.kind === 'string'
109      && typeof r.sql === 'string' && typeof r.rows === 'number' && typeof r.agent === 'string' && typeof r.fingerprint === 'string'
110    return ok ? (r as Req) : null
111  } catch {
112    return null
113  }
114}
115
116async function load(io: IO, id: string): Promise<Req | null> {
117  if (!ID_RE.test(id)) return null
118  try {
119    return parse(await io.read(`${await requestsDir(io)}/${id}.json`), id)
120  } catch {
121    return null
122  }
123}
124
125export async function findPending(io: IO): Promise<Req | null> {
126  const dir = await requestsDir(io)
127  let names
128  try {
129    names = await io.list(dir)
130  } catch {
131    return null
132  }
133  const me = await identity(io)
134  const now = (await io.now()) / 1000
135  const mine: Req[] = []
136  for (const n of names) {
137    const m = /^(w-[0-9a-f]{12})\.json$/.exec(n.name)
138    if (!m || n.kind !== 'file' || n.isLink || n.size > 1024 * 1024) continue
139    const r = await load(io, m[1])
140    if (r && r.status === 'pending' && r.expires > now && me.includes(r.agent)) mine.push(r)
141  }
142  mine.sort((a, b) => Number(a.created ?? 0) - Number(b.created ?? 0))
143  return mine[0] ?? null
144}
145
146// Best-effort audit line in the wrapper's default log. Names only, never values.
147async function audit(io: IO, event: string, r: Req, extra: Record<string, unknown>) {
148  try {
149    const file = `${(await io.env('SAFE_PSQL_HOME')) || `${(await io.env('HOME')) ?? ''}/.safe-psql`}/write.log`
150    const line = JSON.stringify({
151      ts: new Date(await io.now()).toISOString().replace(/\.\d+Z$/, 'Z'), event, request: r.id, agent: r.agent,
152      environment: r.environment, table: r.table, kind: r.kind, param_names: r.param_names ?? Object.keys(r.params ?? {}).sort(),
153      rows: r.rows, via: 'claude-code-mod', ...extra,
154    })
155    const before = (await io.exists(file)) ? String(await io.read(file)) : ''
156    await io.write(file, before + line + '\n')
157  } catch { /* the log is a courtesy; the state change is what counts */ }
158}
159
160async function save(io: IO, r: Req) {
161  await io.write(`${await requestsDir(io)}/${r.id}.json`, JSON.stringify(r))
162}
163
164async function clear($: Engine) {
165  current = null
166  typed = ''
167  await update($, waiting, () => null)
168  await $.ui.close({ id: PANE })
169}
170
171export async function approve(io: IO, shown: Req, answer: string): Promise<{ ok: boolean; message: string }> {
172  const r = await load(io, shown.id)
173  if (!r) return { ok: false, message: 'The request is gone.' }
174  if (r.status !== 'pending') return { ok: false, message: `The request is ${clean(r.status, 20)}, not pending.` }
175  if (r.expires <= (await io.now()) / 1000) return { ok: false, message: 'The request expired. The agent has to propose it again.' }
176  if (r.fingerprint !== shown.fingerprint) return { ok: false, message: 'The request changed after you saw it. Look at it again.' }
177  if (answer.trim() !== r.environment) return { ok: false, message: `Type the environment name exactly (${clean(r.environment, 40)}) to approve.` }
178  const now = (await io.now()) / 1000
179  const who = (await io.env('USER')) || 'unknown'
180  r.status = 'approved'
181  r.approved_by = who
182  r.approved_at = now
183  r.apply_by = now + (typeof r.ttl === 'number' ? r.ttl : 600)
184  r.approved_fingerprint = r.fingerprint
185  await save(io, r)
186  await audit(io, 'approved', r, { approver: who })
187  return { ok: true, message: 'Approved.' }
188}
189
190export async function reject(io: IO, shown: Req): Promise<void> {
191  const r = await load(io, shown.id)
192  if (!r || (r.status !== 'pending' && r.status !== 'approved')) return
193  r.status = 'rejected'
194  r.params = null
195  r.diff = null
196  await save(io, r)
197  await audit(io, 'rejected', r, { by: (await io.env('USER')) || 'unknown' })
198}
199
200async function poll($: Engine) {
201  if (current) {
202    // keep showing it only while it is still pending (the person may have answered in a terminal)
203    const again = await load(ioOf($), current.id)
204    if (!again || again.status !== 'pending' || again.expires <= (await $.clock.now()) / 1000) await clear($)
205    else return
206  }
207  const r = await findPending(ioOf($))
208  if (!r) return
209  current = r
210  await update($, waiting, () => r.id)
211  $.ui.toast(`A database change is waiting for your approval (${clean(r.environment, 30)})`)
212}
213
214export const register: Register = on => {
215  on('session.start', async ($, e, next) => {
216    await $.command.register({ name: 'sql-approval', description: 'Review a database change an agent is waiting to make' })
217    await poll($)
218    $.clock.every(POLL_MS, () => { void poll($) })
219    return next(e)
220  })
221
222  on('command.run', { command: 'sql-approval' }, async $ => {
223    await poll($)
224    if (!current) return { text: 'No database change is waiting for you.' }
225    await $.ui.open({ ...PANE_ARGS, title: `Approve ${current.kind} on ${clean(current.table, 40)}` })
226    return { text: 'Opened.' }
227  })
228
229  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
230    const id = await read($, waiting)
231    if (id === null || !current) return next(e)
232    const { Box, Button, Text } = $.ui.resolve(e)
233    return (
234      <Box>
235        <Text bold>
236          An agent wants to {clean(current.kind, 20)} {Math.trunc(current.rows)} row(s) in {clean(current.table, 40)} ({clean(current.environment, 30)}).{' '}
237        </Text>
238        <Button key="review-sql" label="Review" variant="primary" onPress={() => { void $.ui.open({ ...PANE_ARGS, title: `Approve ${current?.kind ?? ''} on ${clean(current?.table ?? '', 40)}` }) }} />
239        <Text> </Text>
240        <Button
241          key="reject-sql"
242          label="Reject"
243          onPress={async () => {
244            const shown = current
245            if (!shown) return
246            await reject(ioOf($), shown)
247            await clear($)
248            await $.prompt.submit({ text: `[SQL WRITE] request ${shown.id} was rejected by the user. Check it with safe-psql-write status ${shown.id}. Do not retry the same change or a wider one; ask what they want instead.` })
249          }}
250        />
251      </Box>
252    )
253  })
254
255  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
256    const ui = $.ui.resolve(e)
257    if (!('Input' in ui)) return <ui.Text>This screen cannot take typed input. Use Claude Code in a terminal, or approve with safe-psql-write approve.</ui.Text>
258    const { Box, Text, Input } = ui
259    const r = current
260    if (!r) return <Text>No database change is waiting.</Text>
261    const themeRow = (await $.config.list()).find(row => row.key === 'theme')
262    const FG = /light/i.test(String(themeRow?.value ?? '')) ? FG_LIGHT_THEME : FG_DARK_THEME
263    const line = (t: string, key: string) => <Text key={key} color={FG}>{t}</Text>
264    return (
265      <Box flexDirection="column" paddingX={1}>
266        <Text bold color={FG}>Approve a database change</Text>
267        {line(`Request ${clean(r.id)} from agent '${clean(r.agent)}'`, 'h')}
268        {line(`environment : ${clean(r.environment)}`, 'env')}
269        {line(`statement   : ${clean(r.kind)} on ${clean(r.table)}, ${Math.trunc(r.rows)} row(s), limit ${Math.trunc(Number(r.max_rows) || 0)}`, 'st')}
270        {line(`SQL         : ${clean(r.sql.replace(/%%/g, '%'), 600)}`, 'sql')}
271        {line(`values      : ${clean(JSON.stringify(r.params ?? {}), 600)}`, 'val')}
272        {r.reason ? line(`reason      : ${clean(r.reason, 200)}`, 'why') : null}
273        {line('what changes:', 'wc')}
274        {describeDiff(r).map((t, i) => line(`  ${t}`, `d${i}`))}
275        <Text color={FG}> </Text>
276        <Input
277          key="sql-confirm"
278          label={`Type ${clean(r.environment, 40)} to approve: `}
279          submitLabel=" "
280          autoFocus
281          onInput={(v: string) => { typed = v }}
282          onSubmit={async (value: string) => {
283            const shown = current
284            if (!shown) return
285            let res
286            try {
287              res = await approve(ioOf($), shown, value || typed)
288            } catch {
289              $.ui.toast('Could not write the approval. Approve with safe-psql-write approve instead.')
290              return
291            }
292            if (!res.ok) { $.ui.toast(res.message); return }
293            await clear($)
294            $.ui.toast('Approved. The agent will be told.')
295            await $.prompt.submit({ text: `[SQL WRITE] request ${shown.id} was approved by the user. Confirm with safe-psql-write status ${shown.id}, then run safe-psql-write apply ${shown.id} once and report the row count.` })
296          }}
297        />
298        <Text color={FG}>Enter approves only if the name matches. Esc closes; the band above the prompt stays. Reject is on the band.</Text>
299        <Text color={FG}>Applying is still the agent's step and repeats every check.</Text>
300      </Box>
301    )
302  })
303}
304
types/index.d.ts 8 lines
1export type Waiting = string | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    'ai-maestro-sql-approval': { waiting: Waiting }
6  }
7}
8