Files

229 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: notify
description: Reach Gabriel from an agent session through the ai-agent hub — a push notification to his phone (notify send), a 2-3-button choice he taps (notify ask), or a structured form he fills in the viewer (notify form) — and collect the answer without blocking the turn (notify wait). Works identically on the lab host and on a remote worker. Use when work is finished, when the user wants to be pinged, or when you need a decision or details from him while he may be away from the terminal.
trigger_words:
- notify
- notify me
- ping me
- let me know when
- ask me
- clarifying questions
---
# notify — push · ask · form, through the hub
One CLI, `cli/notify.py` in the ai-agent repo, is how a session talks to
Gabriel. Every call goes to the **ai-agent hub**, the notification source of
truth: it records the event, links it to this conversation, forwards it to
Home Assistant (phone push + the desk phone), and takes the answer back.
| command | what Gabriel gets | you get back |
|---|---|---|
| `notify send [TITLE] MESSAGE` | a push (+ the desk phone reads it) | nothing — fire and forget |
| `notify ask "Q?" "A" "B" ["C"]` | a push with 2–3 tappable buttons | the chosen label |
| `notify form --title … --fields …` | a form inline in the conversation | the answers as JSON |
| `notify wait <id>` | — | the answer of an `ask_…` / `frm_…` |
| `notify cancel <frm_id>` | the form is dismissed | — |
The old names still work and mean exactly the same thing: **`notify-done`** =
`notify send`, **`notify-ask`** = `notify ask`, **`ask-form`** = `notify form`
(with its own `wait` / `cancel`). Pick whichever the task's guidelines name.
## Where it runs
The CLI is stdlib Python, and the sidecar puts `cli/` on every run's PATH, so
it is available wherever the hub launched the session:
- **On the lab host** it POSTs to the hub directly (`AI_AGENT_URL`, default
`http://127.0.0.1:8096`).
- **On a remote worker** (a paired Mac) the sidecar exports
`AI_AGENT_OUTBOX_URL`: the call is queued in *that sidecar's outbox* and the
hub — which polls every paired worker every ~2 s — collects it, records it,
forwards it to the phone and pushes the answer back. The worker never calls
the hub. Latency is a couple of seconds; `wait` behaves the same.
Ids (`ntf_…`, `ask_…`, `frm_…`) are minted by the CLI, so the id printed on a
worker is the id the hub, the phone and the viewer use.
## 1. Push — `notify send`
```bash
notify send "Deploy finished ✅" # title defaults to "Claude Code"
notify send "homelab" "Traefik reloaded, all services up ✅" --type service
notify send "gabvdl deployed ✅" "live now" --type deploy --url https://www.dev.gabvdl.xyz
notify send "Task done ✅" "PR opened for review" --type git --final \
--say "La PR est ouverte, tout est prêt pour la relecture."
```
- **`--url`** is optional. Omitted, the hub links **this conversation** in the
viewer (it resolves the session's transcript itself — worker sessions
included). Pass a more relevant page when there is one (a deployed site, a
dashboard, a kanban ticket).
- **`--type`** picks the icon, colour and Android channel: `deploy` `code`
`build` `git` `service` `ai` `music` `media` `research` `logs` `dns` `auth`
`mail` `success` `error` `warn` `backup`. `--channel` / `--importance` /
`--vibration` override a single push.
- **`--final`** marks the task's closing push: the hub stamps the session
*finished* (what the terminal's `DONE` line means). Use it on the last
notification of a task, not on progress pings.
- **`--say` / `--spoken`** — one short, factual, hopeful French sentence the
desk phone reads aloud instead of the screen text (Pocket-TTS, no emotion
markup: the tone is in the wording).
- **`--image <url|/local/file>`** shows a picture. A `--type deploy` push with
a public `--url` auto-attaches that site's `/og-image.png` when it answers
200 (lab hosts are skipped — the phone can't validate their certs;
`HA_NOTIFY_NO_OG=1` disables it).
- **`--action-cmd "Title::shell command"`** adds a button; tapping it runs the
command (a detached waiter long-polls the hub for up to 6 h). This is how
`commit-project`'s **Merge to main** button works.
- The **cost line** (`Claude Code · $1.57 · 78k tokens · 8m 38s`) is appended
automatically from this session's transcript; `--no-cost` drops it.
## 2. Choice — `notify ask`
For a decision Gabriel can make with one tap. The first arg is **one sentence
that names every option**; the rest are the **button labels** (2 or 3, short
verbs: `Rebuild`, `Skip`, `Cancel` — Android shows at most 3).
**Never block the turn.** Send with `--background`, arm a Monitor on the wait,
end the turn; the answer wakes you:
```bash
notify ask --background \
"Where should I deploy the build: staging, production, or cancel?" \
"Staging" "Production" "Cancel" --type deploy --timeout 900
# → ASK_ID=ask_4f2c91ab77de
```
```
Monitor: command = "notify wait ask_4f2c91ab77de", persistent = true,
description = "waiting for the deploy-target choice"
```
- `notify wait <ask_id>` prints the chosen label and exits `0` the moment a
button is tapped; `3` when the ask expires (`--timeout`, default 180 s —
give a backgrounded ask a generous one).
- While pending, the conversation sits in the viewer's **Waiting for
feedback** section, answerable from the card as well as from the phone.
- When the answer lands, TaskStop the monitor if still armed. If you stop
waiting, TaskStop it too — the ask expires on its own.
- Without `--background` the call waits inline and prints the label — fine in
a shell script, wrong in an agent turn.
- Only for questions that genuinely block the work while Gabriel is plausibly
away. For anything richer than 2–3 buttons, use a form.
## 3. Form — `notify form`
```bash
notify form --title "Project setup" --description "A few details before I scaffold." \
--type ai --fields '[
{"type":"markdown","label":"Context","text":"I found **two** candidate stacks."},
{"key":"name","label":"Project name","type":"text","required":true},
{"key":"stack","label":"Stack","type":"select","required":true,
"options":["Vite + React","Next.js","Astro"]},
{"key":"features","label":"Features","type":"multiselect",
"options":[{"value":"auth","label":"Auth"},{"value":"db","label":"Database"}]},
{"key":"deploy","label":"Deploy after scaffold","type":"checkbox"},
{"key":"polish","label":"Polish level","type":"slider","min":1,"max":5,"default":3},
{"key":"logo","label":"Logo","type":"file","accept":"image/*"},
{"key":"deadline","label":"Deadline","type":"date"}
]'
# → FORM_ID=frm_… + the focus URL; exits immediately
```
Field types: `text` `textarea` `number` `select` `multiselect` `radio`
`checkbox` `slider` `file` `date`, plus two display blocks: `markdown`
(`{"type":"markdown","text":"…"}`) and `html` (below), each with an optional
`label` heading. Options are strings or `{value,label}`. Description, help,
labels and options render markdown. `--fields-file <path>` or JSON on stdin
for big specs.
**"Other…" on choice fields.** A **required** `select` / `radio` /
`multiselect` automatically gets an *Other…* choice with a free-text input, so
a mandatory question whose options miss the real answer stays answerable;
`"other": true|false` forces it on an optional field or removes it. The typed
text comes back **as the value** (a string outside the options; in a
multiselect array, one extra free string) — so read such answers as "one of my
options, or Gabriel's own words".
### `html` blocks — visualisations, schemas, images, videos
`{"type":"html","label":"…","html":"<…>"}` puts raw markup in the form's flow:
a chart, an architecture schema, a before/after screenshot, a video clip, a
table you styled yourself. Two rendering paths, picked per block:
- **Inline (default)** — sanitized (DOMPurify) and rendered in the PWA's own
DOM, inheriting its theme: `<img>`, `<video controls>`, `<audio>`, inline
`<svg>`, tables, inline `style=""` all work. No `<script>`, no `<style>`
sheet, no `<iframe>`, no event handlers (stripped).
- **Sandboxed** — `"sandbox": true`, or automatically when the markup carries
a `<script>`, `<style>`, `<link>` or `<iframe>`: the block becomes a
`srcdoc` iframe with `sandbox="allow-scripts"` (opaque origin: no access to
the PWA, its cookies or storage). Scripts run, so Chart.js / mermaid /
three.js / d3 visualisations work; libraries may be loaded **only from
cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com or esm.sh** (CSP;
fonts from Google Fonts) — anything else must be inline. The iframe sizes
itself to its content (`"height": 320` pins it instead). The PWA's theme
tokens are injected, so `hsl(var(--primary))`, `var(--muted-foreground)`,
`var(--code-string)`… match the viewer in CSS/SVG, and `<html class="dark">`
is set in dark mode. Canvas libraries (Chart.js, three.js) can't resolve
`var()` — give them literal colours, or read a token first:
`getComputedStyle(document.documentElement).getPropertyValue('--primary')`
(an HSL triplet, e.g. `24 95% 53%`). A full `<html>` document is accepted
as is.
**Local files** (a screenshot you just took, a rendered diagram, a clip) go in
with `--asset <file>` (repeatable) and are referenced as
`{{asset:<basename>}}` anywhere in the spec — the hub stores them like
composer uploads and substitutes the serve URL. Works unchanged from a Mac
worker (the file travels base64 inside the payload; 20 MB per file).
```bash
notify form --title "Which layout?" --type ai \
--asset .ai/artefacts/$CLAUDE_SESSION_ID/before.png \
--fields '[
{"type":"html","label":"Current page",
"html":"<img src=\"{{asset:before.png}}\" style=\"border-radius:8px\">"},
{"type":"html","label":"Weekly cost","sandbox":true,"height":260,
"html":"<canvas id=c></canvas><script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script><script>new Chart(c,{type:\"bar\",data:{labels:[\"Mon\",\"Tue\"],datasets:[{data:[3,5],backgroundColor:\"hsl(24 95% 53%)\"}]}})</script>"},
{"key":"layout","label":"Layout","type":"radio","required":true,
"options":["Sidebar","Tabs"]}
]'
```
Blocks are display-only (never answered, skipped in the recap) and capped at
512 KB each. Keep HTML out of `markdown` blocks and the description — they
render markdown only.
The hub pushes the "📋 title" notification itself, deep-linked to the form
(`?form=<id>` opens it full-screen in the PWA). Then, as for an ask:
```
Monitor: command = "notify wait frm_XXXX", persistent = true,
description = "waiting for form answers"
```
- `wait` prints one JSON line keyed by field key on submit (exit `0`);
cancelled ⇒ `3`; `--timeout N` reached ⇒ `4` (the form stays pending —
`notify cancel <id>` it or keep waiting).
- `multiselect` answers are arrays of option values; `checkbox` a boolean;
`file` answers `[{name, repoPath, url}]` (the upload lands under
`services/ai-agent/data/uploads/`).
- Keep working on everything that doesn't depend on the answers; fold them in
when they land. A stale pending form sits in *Waiting for feedback* forever,
so cancel what you give up on.
- The same field spec is what the `plan` skill embeds under `## Questions`.
## Notes
- `notify cost` prints the cost line alone; `--dry-run` on `send` / `ask` /
`form` prints the exact payload instead of sending.
- Env: `AI_AGENT_URL` (hub), `AI_AGENT_OUTBOX_URL` + `AI_AGENT_OUTBOX_TOKEN`
(set by a worker's sidecar), `CLAUDE_SESSION_ID` (set by the sidecar for
every run; the lab's shims resolve it otherwise), `HA_NOTIFY_TYPE` /
`HA_NOTIFY_URL` / `HA_NOTIFY_IMAGE` / `HA_NOTIFY_SPOKEN` as flag defaults.
- There is no direct Home-Assistant fallback any more: if the hub is down the
call fails loudly (exit 1) rather than pushing twice later.
- Claude Code's interactive AskUserQuestion is disabled for spawned sessions —
`ask` and `form` are its replacement.