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…

<img src="./docs/logo-constellation.svg" alt="AI Maestro Logo" width="120"/>
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.

Quick Start · Features · Skills · Protocols · Documentation · Contributing
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:
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.
curl -fsSL https://raw.githubusercontent.com/23blocks-OS/ai-maestro/main/scripts/remote-install.sh | sh
This installs everything you need:
aim-secret and safe-psql helpersTime: 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.
Every feature was born from running a real AI-first organization. We built them in the order we needed them.
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.
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.
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.
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.
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.
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 →
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 →
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.
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 →
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 →
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.
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.
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:
| Mode | What | Best For |
|---|---|---|
| tmux | Direct terminal sessions on your machine | Local development, zero setup |
| Docker | Containerized agents with resource limits | Isolation, reproducibility, multi-project |
| AWS EC2 | Dedicated Graviton instance with native install, Nginx + SSL | Always-on agents, SSH access, persistent workloads |
| AWS ECS Fargate | Serverless containers, auto-built Docker image | Burst 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
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:
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 →
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.
| Skill | What it does | Source |
|---|---|---|
memory-search | Search what the agent learned in past sessions, and what depends on an entity | plugin builder |
docs-search | Look up signatures and APIs in the generated docs | plugin builder |
graph-query | Callers, dependencies and blast radius from the code graph | plugin builder |
ai-maestro-agents-management | Create, rename, hibernate, wake and delete agents | plugin builder |
planning | Persistent plan, findings and progress files for long tasks | plugin builder |
aim-secret-management | Use a credential without seeing it; ask the user to store one | plugin builder |
agent-messaging | Signed messages between agents (AMP) | agentmessaging/claude-plugin |
agent-identity | Sign in with an Ed25519 identity and OAuth 2.0 tokens (AID) | agentmessaging/agent-identity |
canvas-actions | Interactive pages in the dashboard and their actions (AAP) | agentmessaging/agent-actions |
agent-files | Share files across hosts through an AFP space | agentmessaging/agent-files |
agentic-sql | Read-only SQL against real databases through safe-psql; changes data only through safe-psql-write, after a person approves each change | 23blocks-OS/agentic-sql |
agentic-support | Numbered support cases with a written trail, and runbooks | 23blocks-OS/agentic-support |
agentic-seo | SEO audit and implementation for websites | 23blocks-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.
AI Maestro implements four open protocols, all under github.com/agentmessaging. Each works without the others.
| Protocol | Covers | Specification |
|---|---|---|
| AMP — Agent Messaging Protocol | Signed messages between agents, local or federated | agentmessaging.org |
| AID — Agent Identity | Ed25519 agent identity and OAuth 2.0 tokens | agentids.org |
| AAP — Agent Actions Protocol | Actions from agent-drawn pages back to the agent | agentactions.org |
| AFP — Agent Files Protocol | Files shared by reference through S3-compatible storage | agentmessaging/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)
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

Agent Inbox — Direct agent-to-agent messaging

New here?
Going deeper:
Troubleshooting:
Open Protocols:
Extending:
See the full roadmap.
We love contributions. See CONTRIBUTING.md for guidelines.
AI Maestro is better because of people outside the team who hit something, read the source, and sent a fix. In order of arrival:
hooks/register.tsx 304 lines1import { 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}
304types/index.d.ts 8 lines1export type Waiting = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'ai-maestro-sql-approval': { waiting: Waiting }
6 }
7}
8