diff --git a/.gitignore b/.gitignore index e7ace45..c0728e4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,10 @@ CLAUDE.md SECURITYPLAN.md .gstack/ node_modules/ +.DS_Store +__pycache__/ +# Scratch workspaces from an earlier agent experiment; not part of the Django app. +cloudpebble/agent/ + +# Model bench artifacts: screenshots and per-run summaries +tests/model_bench/results/ diff --git a/AGENT_E2E_RUN.md b/AGENT_E2E_RUN.md new file mode 100644 index 0000000..d89bde9 --- /dev/null +++ b/AGENT_E2E_RUN.md @@ -0,0 +1,157 @@ +# New-user end-to-end run — 6 August 2026 + +One person who cannot code, sitting in front of CloudPebble, on the free tier +(`deepseek-v4-flash` for the work, `mistral-small-3.2-24b` as its eyes). Everything +below was done through the browser: create a project, type into the chat panel, look at +what came back. No API calls, no shortcuts. + +Two projects: + +| | Project 67 "Aurora Face" | Project 68 "Dice Roller" | +|---|---|---| +| Type | Alloy (JavaScript SDK), tutorial template | Pebble C SDK, empty project | +| Asked for | big time, day/date, battery bar, aurora colours | roll dice on a button press, big number, shake animation, colour | +| Turns | 5 | 3 | +| Outcome | working, installed, verified by screenshot | working, installed, verified by screenshot | + +Both were built, installed and visually verified by the agent itself. The free tier +cannot see, so every screenshot went through the describer — 3 to 6 describer calls per +turn, about $0.0001 each. + +## What the run cost, in the user's time + +The dice app was right on the first build: two dice with correct pips, a total, button +hints. Three turns, no dead ends, and it added colour and a tumble animation when asked. + +The watchface took five turns and roughly 25 minutes, and most of that was spent on +problems that had nothing to do with the watchface. One turn burned its entire 30-step +budget. A user without an engineer watching would have given up somewhere around the +third "emulator rejected the install". + +## What blocked a new user, and what was done about it + +**The agent could not read its own reference guides.** `Read` is banned and `read_file` +only sees project files, so the Alloy guide shipped inside the skill was unreachable. The +model tried both routes, failed, and then spent a whole 30-step budget rediscovering by +trial that `import Battery from "battery"` does not exist in that runtime — which is what +the guide says. *Fixed: a `read_reference` tool, path-checked to the skills directory. +On the retry the model read the guide, found the real API (`embedded:sensor/Battery`), +and shipped the battery bar in one turn.* + +**"Emulator rejected the install" read as "your code is broken".** qemu answers BlobDB +errors that way and CloudPebble's own UI responds by telling the user to reboot. The +agent, told only "status 1", concluded its app was crashing and began deleting working +features to bisect — it removed the battery bar, then reverted to the stock template. +*Fixed: the tool now says it is an emulator problem, not the code, and to ask the user to +reboot.* + +**A turn could never see a new emulator.** The descriptor is captured when the turn +starts. Reboot the emulator mid-turn — which is exactly what the rejection asks for — and +every later install and screenshot dials a dead instance until the turn ends. *Fixed: an +agent-scoped endpoint returns the live emulator, and the tools re-resolve through it when +a connection dies.* + +**Nothing but the user can open an emulator, and nothing said so.** Both projects built +on the first turn and dead-ended on "no emulator is running -- open the emulator in +CloudPebble first". The agent explains it well, but a new user does not know that means +Build & Run → Emery. *Fixed: those failures now carry an Open emulator / Restart emulator +button that drives Build & Run's own control.* + +**No visible way to queue a message mid-turn.** Stop replaced Send, so Enter was the only +route to the queue and nothing advertised it; clicking where Send had been did nothing. +*Fixed: Send stays visible and becomes Queue.* + +**An invisible modal ate the keyboard.** CloudPebble leaves its install-progress modal +open behind the reboot prompt. Dismiss the prompt and the page keeps a focus-trapping +modal nobody can see: the chat composer cannot be typed into again until a reload. This +one predates the agent and is easy to hit without it. *Fixed: the progress modal is +hidden before the prompt goes up.* + +## Still open + +- **One emulator per user, shared by every project.** Both tabs got the same instance + (`qemu-user--`), so two projects overwrite each other's installed app and + a screenshot can show the wrong project's work. Pre-existing; the agent makes it much + easier to hit. +- **30 steps is not enough for an unfamiliar runtime.** The Alloy turn ended mid- + experiment with `main.js` left as a debug probe. It recovered on the next turn, but the + step limit should scale with how much trouble the turn is in. +- **`max_turns` prints twice** — the friendly sentence, then the raw SDK line. +- **An empty chat panel offers a new user nothing**: no examples, no hint that an + emulator will be needed, no sense of what this thing can do. +- **The model reached for a `run` skill that does not exist here**, and for + `Skill: pebble-watchface:reference/...` as a way to read a file. Both are harmless + noise, both cost a step. + +--- + +# Second run — 7 August 2026 + +Same setup, harder briefs, after the first run's fixes. Step limit raised to 75, the +emulator now opens itself, and the model is told what tools it has. + +| | Project 69 "Tide Clock" | Project 70 "Space Now" | +|---|---|---| +| Type | Pebble C SDK, empty project | Pebble C SDK, empty project | +| Asked for | big time, ocean scene whose water level tracks the tide, a wave animation on every minute change | how many people are in space right now with their names, live from a free API, down to scroll | +| Outcome | working, animated, verified | fetches live data, proven in logs; last screenshot still showed the demo list | + +## What the fixes bought + +`read_reference` was the first tool both agents reached for -- the Alloy guide, the API +reference, the animated-watchface template, the watchapp guide. No more trial and error +against an unfamiliar runtime. + +The emulator opened itself. The space app hit "no emulator is running", the panel started +one and queued its own "carry on", and the agent continued. No user action, no button. + +"The emulator rejected the install" now says it is an emulator problem: the tide agent +read it, said so to the user, and did NOT start deleting working features to bisect -- +which is exactly what happened in the first run. + +## What this run found + +**`logs()` had never worked.** The watch ships APP_LOG output only after it is asked to; +the browser sends APP_LOGS=1 to endpoint 2006 after every install and the agent never did. +Four drains, "0 log lines" every time, so the space app gave up on the live fetch it had +been asked for and shipped an offline demo. With the fix it read the logs, found that +HTTPS is blocked in the emulator sandbox but plain HTTP works, switched, and got +`JS: ok via source1 200 (12 people)` -> `[C]: Space Now inbox received`. The whole feature +turned on the ability to read a log line. + +**The automatic emulator started the worst watch.** Aplite is first in DOM order, so a +colour watchface got designed and verified on a 144x168 black and white screen. It now +prefers the platform the agent is laying out for, then the richest available -- the tide +face moved to emery and stopped looking like a fax. + +**A deploy orphans every in-flight turn.** The relay is a thread inside a web worker. I +killed both runs twice this way; sessions sat 'running' with disabled composers until the +stale timeout half an hour later. The web container now releases them at startup with an +event that says so and that "continue" resumes. Verified: 3 released on the next deploy. + +**Two projects share one emulator, and it is worse than it looks.** Screenshots showed the +other project's app; logs carried the other project's phone-side JS; installs kicked each +other off until both agents were retrying against a moving target. The agents each +diagnosed it correctly and kept working, which is the best that can be done from their +side. This needs an emulator per project, or a lock. + +**The model still defaults to a watchface.** "Space Now", an explicit app request with +button scrolling, was built as a watchface with a big clock until corrected. The skill is +watchface-first and the project name nudged it. It switched instantly when told. + +**Correcting the kind of thing being built does not correct the setting.** Told "this +isn't a watchface, I want an app", the model rewrote the code as an app -- and left +`app_is_watchface = true`, so it still installs over the user's clock instead of appearing +in the app menu. The skill is emphatic that a watchface must be flagged; it needs to be +just as emphatic in the other direction, and to re-check the flag whenever the user +redescribes what they are building. + +**A still frame cannot prove an animation.** The tide agent reported the wave as working +from a single screenshot. Asked to prove it across a minute boundary, it discovered the +wave was not animating at all, added it, and then showed it moving. Worth teaching: for +anything time-driven, several shots and a statement of what changed between them. + +## Environment note + +Chromium cannot sandbox on this host (`kernel.apparmor_restrict_unprivileged_userns=1`), +so browser automation needs `GSTACK_CHROMIUM_NO_SANDBOX=1`. diff --git a/AGENT_PLAN.md b/AGENT_PLAN.md new file mode 100644 index 0000000..94c5ffd --- /dev/null +++ b/AGENT_PLAN.md @@ -0,0 +1,442 @@ +# CloudPebble AI Agent — build plan + +Chat panel in the CloudPebble IDE that builds Pebble watchfaces with AI, builds them +with the existing build farm, and installs them into the emulator the user is already +watching. + +**Core principle: we own no agent loop.** The loop is +[Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/hosting). The watchface +expertise is [`coredevices/pebble-watchface-agent-skill`](https://github.com/coredevices/pebble-watchface-agent-skill). +The build system and emulator are the ones already running in production. We write glue. + +Scope for v1: **watchfaces, native C, existing projects.** + +--- + +## 0. Decisions + +| | | +|---|---| +| Target environment | `cloudpebble-dev.exe.xyz` — the existing dev instance, not prod | +| Emulator | The user's live browser-launched emulator. Agent installs into what they're watching. | +| Chat UI | Straight into the IDE, third column. No throwaway standalone page. | +| Loop host | New exe.dev VM, separate from the dev instance | +| Model | Sonnet 5 default. Opus toggle deferred — A/B during dogfood, layout+screenshot work is where it would pay. | +| Auth | Eric's Claude Max subscription via `CLAUDE_CODE_OAUTH_TOKEN`. No API key. | + +### Model auth + +[Supported](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan): +Agent SDK usage in your own projects draws from your Claude subscription's usage limits. +Headless mechanism is a long-lived OAuth token: + +```bash +claude setup-token # once, interactively, on a machine with a browser + # → sk-ant-oat01-..., ~1 year validity +``` + +Set `CLAUDE_CODE_OAUTH_TOKEN` on the agent VM. The SDK picks it up with no other config. + +Three consequences that are build-relevant, not paperwork: + +- **Usage is shared with Eric's interactive Claude Code.** A screenshot-heavy agent loop + burns the same pool he codes against — images are expensive. The per-user turn cap in §6 + is now protecting his ability to work, not a bill. Keep it low to start. +- **Limit exhaustion is a runtime failure mode.** A turn can die mid-flight when the weekly + limit hits. Surface it in chat as "Claude usage limit reached", distinct from a build + error, and don't let the model retry into the wall. +- **One token, one identity.** Correct for dogfood. Before this reaches other CloudPebble + users, revisit — serving many users off one personal subscription is a different question + than using your own plan for your own project. Phase 8+ decision, not a blocker now. + +Rotation: the token expires in about a year and `claude setup-token` is interactive. Note +it in the deploy runbook so it doesn't fail silently twelve months from now. + +### Emulator liveness + +The user's emulator dies with their browser session (`SharedPebble.handleEmulatorDisconnected`). +So `install()` and `screenshot()` can fail mid-turn through no fault of the agent. Handle it +explicitly: the tool returns a typed "no emulator" error, the model reports it in chat as +"open the emulator to see this run" rather than treating it as a build failure and looping. +The `build()` tool never depends on the emulator, so a session with no emulator still makes +progress — it just can't visually verify. + +--- + +## 1. Architecture + +``` +browser (existing IDE) cloudpebble (prod) agent VM (new, exe.dev) +┌───────────────────────┐ ┌────────────────────┐ ┌──────────────────────┐ +│ #chat-wrapper │──POST───▶│ /ide/agent/message │───────▶│ POST /turn │ +│ (new jQuery pane) │ │ mints scoped token│ │ claude-agent-sdk │ +│ │◀──SSE────│ /ide/agent/stream │◀───────│ streams events │ +├───────────────────────┤ │ │ │ │ +│ #sidebar-wrapper │ │ SourceFile (DB) │◀──HTTPS┤ tools: │ +│ emulator canvas ─────┼──ws──┐ │ Celery run_build │◀───────┤ read/write_file │ +│ file tree │ │ │ AgentSession │ │ build │ +├───────────────────────┤ │ └────────────────────┘ │ install │ +│ #pane-parent (editor) │ │ │ screenshot │ +└───────────────────────┘ │ ┌────────────────────┐ │ logs │ + └──▶│ qemu-controller │◀──ws───┤ (libpebble2) │ + │ (Hetzner) │ └──────────────────────┘ + └────────────────────┘ +``` + +Three things run in three places, and only the middle one holds state: + +| | Where | State | +|---|---|---| +| Chat UI | browser, existing IDE | none | +| Files, builds, sessions | CloudPebble prod | Postgres + S3 (authoritative) | +| Agent loop | new exe.dev VM | none — transcripts mirrored to CloudPebble | + +The agent has **no filesystem, no compiler, no emulator, and no `bash`.** Every action it +takes is an HTTPS call to CloudPebble or a libpebble frame to the emulator. That is what +makes it stateless and what removes container-escape from the threat model. + +--- + +## 2. The agent VM (new) + +New exe.dev VM, `agent..exe.xyz`, docker + nginx + TLS, same bootstrap as the +README's exe.dev section. + +One container. Python 3.11 + `claude-agent-sdk` + `libpebble2` + `requests` + FastAPI. +No Pebble SDK, no arm toolchain, no qemu. ~200 MB image. + +### Endpoints + +``` +POST /turn {session_id, project_id, cp_token, cp_base_url, emulator, message} + → SSE stream of agent events, then 200 +POST /cancel {session_id} +GET /health +``` + +Auth on the VM: shared secret header from CloudPebble, `AGENT_LAUNCH_AUTH_HEADER`, exactly +like `settings.QEMU_LAUNCH_AUTH_HEADER` does for the qemu controller today. The VM is not +public. + +Model credentials, VM-local only, never in Django and never in the browser: + +```bash +CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... # from `claude setup-token`, see §0 +AGENT_MODEL=claude-sonnet-5 # Opus toggle deferred +``` + +### The turn + +```python +async for msg in query( + prompt=message, + options=ClaudeAgentOptions( + resume=session_id, # None on first turn + session_store=CloudPebbleSessionStore(cp_base_url, cp_token), + cwd='/opt/agent-workspace', # holds .claude/skills/, read-only, no writes + setting_sources=['project'], # so the skill loads + max_turns=12, + allowed_tools=[...], # only our MCP tools — no Bash, no Read/Write + mcp_servers={'cloudpebble': cp_tools(cp_base_url, cp_token, project_id, emulator)}, + env={'CLAUDE_CONFIG_DIR': per_turn_tmpdir, + 'CLAUDE_CODE_DISABLE_AUTO_MEMORY': '1'}, + ), +): + yield sse(msg) +``` + +Stateless: hydrate the transcript from CloudPebble at turn start, mirror it back as it +grows, hold nothing after the response closes. The "hybrid session" pattern from the +hosting guide. Because the workspace is empty and the skill is baked into the image, the +transcript is the complete session state — nothing else to persist. + +### Tools (in-process MCP server) + +| Tool | CloudPebble call | +|---|---| +| `list_files()` | `GET /ide/project//info` → `source_files[]` (name, id, target, file_path) | +| `read_file(path)` | `GET /ide/project//source//load` | +| `write_file(path, content)` | `POST .../source//save`, or `POST .../create_source_file` if new | +| `delete_file(path)` | `POST .../source//delete` | +| `build()` | `POST /ide/project//build/run` → poll `/ide/task/` → `GET .../build/last` + `.../build//log` | +| `install()` | `GET .../build//download/` then libpebble `AppInstaller` over the emulator ws | +| `screenshot()` | libpebble `Screenshot.grab_image()` → PNG, returned to the model as an image block | +| `logs(seconds)` | libpebble app-log service, drained for N seconds | + +### Emulator wire protocol — VERIFIED (spike 0, 2026-07-29) + +Proven end to end from `cloudpebble-loop-dev` against `cloudpebble-dev` over public +`wss://`: launched a basalt emulator, authenticated, requested a screenshot, decoded a +144×168 PNG showing the real watch screen. No browser involved. + +``` +wss://cloudpebble-dev.exe.xyz/qemu//ws/phone +``` + +Frames are length-agnostic binary; **first byte is the opcode**: + +| Dir | Frame | Meaning | +|---|---|---| +| → | `09 ` | v1 auth. `` is the emulator token from `/qemu/launch` | +| ← | `09 00` / `09 01` | auth ok / bad token | +| ← | `08 FF` / `08 00` | connected / closed remotely | +| → | `01 ` | message **to** watch | +| ← | `00 ` | message **from** watch | +| → | `04 ` | install app | +| ← | `05 ` | install finished — this is how `install()` knows it's done | +| ← | `02 ` | pkjs phone log — this is `logs()` | + +Endpoints: `SCREENSHOT=8000`, `APP_LOGS=2006`, `LOGS=2000`, `APP_MANAGER=6000`, +`PUTBYTES=48879`. Screenshot response: 13-byte header `>B I I I` = code, version, width, +height, then raw pixels. Version 2 is 8bpp, 2 bits per channel (`(p>>4)&3, (p>>2)&3, p&3`, +×85); version 1 is 1bpp. + +Three gotchas that cost time and will bite the implementation: + +1. **`/qemu/launch` returns before pypkjs is listening.** Connecting immediately gets + `ECONNREFUSED` through the relay. Sleep ~10s, then retry with backoff. +2. **Don't reconnect to an emulator that already had a client.** Reattaching to a + previously-used or long-idle instance hangs at the auth frame with no error and no log. + One connection per emulator lifetime; if it drops, relaunch. +3. **Inbound is `0x00`, not `0x01`.** `0x01` is outbound-only. Filtering inbound on `0x01` + silently receives nothing forever. + +Reference implementation: `spike_emulator.py` (scratchpad) — ~170 lines, `websocket-client` +only, no `libpebble2` needed. Port it into `cloudpebble-agent/emulator.py` as-is. + +`screenshot()` returning a real image block is the whole reason this works — the model +sees its own watchface and fixes its own layout bugs, which is exactly what the skill's +verification checklist is for. + +--- + +## 3. CloudPebble changes + +### 3.1 Models — `ide/models/agent.py` + +```python +class AgentSession(models.Model): + project = FK(Project, related_name='agent_sessions') + user = FK(settings.AUTH_USER_MODEL) + sdk_session_id = CharField(max_length=64, blank=True) # SDK's session id, for resume + status = CharField(choices=['idle','running','error','cancelled']) + created / last_active = DateTimeField + turn_count = IntegerField(default=0) + +class AgentMessage(models.Model): + session = FK(AgentSession, related_name='messages') + seq = IntegerField # monotonic, for ?since= + role = CharField(['user','assistant','tool','system']) + content = JSONField # rendered event, not raw transcript + created = DateTimeField + +class AgentTranscript(models.Model): # SessionStore backing + sdk_session_id = CharField(unique=True, db_index=True) + session = FK(AgentSession) + data = BinaryField # JSONL batches, append-only +``` + +`AgentMessage` is what the chat panel renders. `AgentTranscript` is what the SDK resumes +from. Keep them separate — one is a UI concern, one is an SDK contract. + +### 3.2 API — `ide/api/agent.py`, mounted under `/ide/` + +| Route | Does | +|---|---| +| `POST project//agent/start` | create `AgentSession`, return id | +| `POST project//agent/message` | mint scoped token, POST `/turn` on the VM, relay SSE into `AgentMessage` rows + redis stream | +| `GET project//agent/stream?since=` | SSE to browser, reads redis stream, falls back to `AgentMessage` rows | +| `POST project//agent/cancel` | `POST /cancel` on the VM, mark cancelled | +| `POST agent/transcript/` | `SessionStore` mirror sink (agent token auth) | +| `GET agent/transcript/` | `SessionStore` hydrate source (agent token auth) | + +Written in the house style: `@login_required @json_view`, `get_object_or_404(Project, pk=..., owner=request.user)`. + +The relay-through-Django choice matters: the browser never talks to the agent VM, so no +CORS, no second auth surface, no public agent endpoint. Same posture as `launch_emulator`, +which mints a token and proxies rather than exposing the controller. + +### 3.3 Scoped agent token + +New: `utils/agent_token.py`. Random 32-byte urlsafe token in redis, +`agent-token- → {user_id, project_id, session_id}`, `ex=1800`, minted per turn. + +A decorator `@agent_token_required` resolves it to `(user, project)` and is accepted **only** +by: `project_info`, source load/save/create/delete, build run/last/log/download, and the +two transcript routes. Nothing else. Precedent for the pattern is +`ide/api/qemu.py:generate_phone_token`, which already does redis-with-TTL tokens. + +The token is the security boundary of this whole feature. It is scoped to one project, one +user, one session, expires in 30 minutes, and cannot touch account settings, other +projects, publishing, or GitHub. + +### 3.4 Build queue separation + +`ide/tasks/build.py:run_build` is a `@shared_task` on the default queue. Agent builds get +`queue='agent_builds'` and their own worker, so a looping agent can't starve human +compiles. Same task, different route. + +### 3.5 Settings + +```python +AGENT_URL = _environ.get('AGENT_URL', '') # https://agent.x.exe.xyz/ +AGENT_AUTH_HEADER = _environ.get('AGENT_AUTH_HEADER', '') +AGENT_ENABLED_USERS = _environ.get('AGENT_ENABLED_USERS', '') # comma ids, feature flag +AGENT_MAX_TURNS_PER_DAY = int(_environ.get('AGENT_MAX_TURNS_PER_DAY', '50')) +``` + +Mirrors the existing `QEMU_URLS` / `QEMU_LAUNCH_AUTH_HEADER` / `YCM_URLS` pattern. + +--- + +## 4. Chat panel + +Third column. Chat is not a pane in the `#main-pane` system — it persists across pane +switches, because you want it visible while looking at the editor. + +``` +.project-container .row-fluid + #chat-wrapper 360px, collapsible, state in localStorage ← new + #sidebar-wrapper emulator canvas + nav + file tree unchanged + #pane-parent #main-pane unchanged +``` + +Emulator stays at the top of the sidebar, so chat and the live watchface sit side by side. +For a watchface the preview *is* the watch — the agent installs into the emulator the user +is watching, and they see it appear. + +New: `ide/static/ide/js/agent.js` (`CloudPebble.Agent`), `ide/static/ide/css/agent.css`, +markup in `ide/templates/ide/project.html`. + +Event rendering: + +| SDK event | Renders as | +|---|---| +| assistant text | bubble | +| `write_file` | collapsed diff card, click opens the file in the real editor pane | +| `build` | status chip, click opens the Build & Run pane | +| `screenshot` | inline image | +| `install` / `logs` | one-line status | +| error / cancel | inline error with retry | + +No React. `useChat` and AI SDK UI are the wrong dependency for a bower/Backbone app; the +SDK's event stream renders fine in ~250 lines of jQuery. + +File conflicts: if the user has unsaved changes in a file the agent wants to write, the +write is refused with a message in chat rather than silently clobbered. Clicking a diff +card reloads that file in the editor. No OT, no live cursors. + +--- + +## 5. Skill + +Fork `coredevices/pebble-watchface-agent-skill` into `agent-vm/skills/pebble-watchface/`, +baked into the image at `/opt/agent-workspace/.claude/skills/`. + +One edit: the "Build & Test Commands" block becomes our tool names. + +``` +pebble build → build() +pebble install --emulator emery → install() +pebble screenshot --no-open ... shot.png → screenshot() +pebble logs --emulator emery → logs() +python3 scripts/create_preview_gif.py → (dropped in v1) +``` + +Everything else carries over untouched, and it is the actual value: emery layout math and +2-5px safety margins, `MINUTE_UNIT` not `SECOND_UNIT`, `sin_lookup`/`cos_lookup` fixed +point only, `layer_get_bounds()` over hardcoded dimensions, AppMessage callbacks registered +before `app_message_open()`, the visual verification checklist, `reference/`, `templates/`. + +Add a CloudPebble delta file: project platforms come from project settings (`app_platforms`) +rather than the skill's emery default, files are addressed by `project_path` not disk path, +and there is no `wscript`/`package.json` editing outside what the source API exposes. + +Track upstream as a real remote so their improvements can be rebased in. + +--- + +## 6. Limits, cost, failure + +- **Turn cap.** `AGENT_MAX_TURNS_PER_DAY` per user in redis. Token cost dominates + everything else "by an order of magnitude or more" — this is the only thing standing + between a bug and a large bill. +- **`max_turns=12`** per turn bounds tool-call round trips. There is no session timeout in + the SDK; this is the bound. +- **Transcript regrowth.** Every turn re-sends the conversation. Prompt caching absorbs + most of it, but long sessions cost more per turn. Watch it; add compaction if needed. +- **No Celery retries on agent work.** A retried turn re-spends tokens and re-applies file + writes. `max_retries=0`. +- **`mirror_error`.** A transcript batch the store rejects retries 3× then is **dropped + silently** while the query continues. Log and alert, or resume will break mysteriously. +- **Feature flag.** `AGENT_ENABLED_USERS` gates the UI and the API. Ship dark. + +--- + +## 7. Security posture + +| Risk | Mitigation | +|---|---| +| Agent runs arbitrary code | It can't. No `bash`, no `Read`/`Write`, no filesystem. `allowed_tools` is our 8 tools. | +| Agent token used to reach other projects | Token is scoped to `(user, project, session)`, 30-min TTL, accepted by 8 endpoints only | +| Agent VM exposed | Not public. Shared-secret header, called only by CloudPebble. Browser never talks to it. | +| Prompt injection via project files | Blast radius is one project's own files. Approve-mode toggle on writes for the paranoid case. | +| Anthropic key leakage | Lives only on the agent VM, never in the browser, never in Django | +| Runaway build spend | Separate build queue + per-user turn cap | + +The thing to keep true as this grows: **the day the agent gets `bash` back, most of this +table stops holding** and the container isolation work from the earlier sketches comes +back. Don't add `bash` casually. + +--- + +## 8. Build order + +| # | Work | Depends on | Est | +|---|---|---|---| +| 0 | ~~**Spike:** emulator ws — auth, screenshot~~ **DONE** — protocol verified, see §2. Install-a-pbw leg still unproven (needs a built pbw) | — | ✅ | +| 1 | ~~Agent VM bootstrap~~ **DONE** — `cloudpebble-loop-dev.exe.xyz` exists: docker 29, python 3.12, uv, `claude` 2.1.220, OAuth token at `~/.agent-env` (0600), reaches dev over HTTPS | — | ✅ | +| 2 | Agent service: FastAPI `/turn`, `claude-agent-sdk`, MCP tools 1-4 (files + build) against prod API with a hand-made token | 0 | 2d | +| 3 | CloudPebble: models, migration, agent token + decorator, `agent/start`/`message`/`stream`/`cancel`, transcript routes | — | 2d | +| 4 | Tools 5-8 (install, screenshot, logs) over libpebble | 0, 2 | 1d | +| 5 | Skill fork + CloudPebble delta, baked into image | 2 | 0.5d | +| 6 | Chat panel: markup, css, `agent.js`, event rendering, diff cards, inline screenshots | 3 | 3d | +| 7 | Session resume via `SessionStore`, cancel, caps, feature flag | 3 | 1.5d | +| 8 | Dogfood, prompt tuning against the 10-watchface set, fix what the screenshots reveal | all | 2d | + +~13 days. Phase 0 gates 4; nothing else is blocked by unknowns. + +Later, explicitly not now: prompt box on the projects page for new-project-from-description, +alloy/JS projects, preview GIFs, publish flow, `bash` + a real container, +[AI SDK `HarnessAgent`](https://ai-sdk.dev/v7/docs/ai-sdk-harnesses) to A/B Claude Code vs +Codex vs Pi. + +--- + +## 9. Repo layout + +``` +cloudpebble-agent/ new top-level dir, deployed to the exe.dev VM + Dockerfile + docker-compose.yml + requirements.txt claude-agent-sdk, libpebble2, fastapi, requests + service.py /turn, /cancel, /health, SSE + tools.py MCP tool definitions + cloudpebble_client.py HTTP client for the CloudPebble API + emulator.py libpebble2 over the qemu ws + session_store.py SessionStore → CloudPebble transcript routes + skills/pebble-watchface/ forked skill + CloudPebble delta + deploy_agent.sh rsync + docker compose up, like deploy_qemu.sh + +cloudpebble/ide/models/agent.py +cloudpebble/ide/api/agent.py +cloudpebble/ide/migrations/00XX_agent.py +cloudpebble/utils/agent_token.py +cloudpebble/ide/static/ide/js/agent.js +cloudpebble/ide/static/ide/css/agent.css +cloudpebble/ide/templates/ide/project.html (edit) +cloudpebble/ide/urls.py (edit) +cloudpebble/cloudpebble/settings.py (edit) +``` + +`deploy_agent.sh` follows `deploy_qemu.sh`: read `.env`, rsync, `docker compose up -d`. diff --git a/cloudpebble-agent/Dockerfile b/cloudpebble-agent/Dockerfile new file mode 100644 index 0000000..1aa0898 --- /dev/null +++ b/cloudpebble-agent/Dockerfile @@ -0,0 +1,25 @@ +# No Pebble SDK, no qemu, no arm toolchain: the agent has no filesystem and no +# shell, every action is an HTTPS call or an emulator websocket frame. +FROM python:3.12-slim + +# claude-agent-sdk ships the claude CLI as a self-contained binary in its wheel, +# so there is no node to install -- only TLS roots for it to reach the API. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Read-only workspace: holds .claude/skills/ and nothing else. +COPY skills /opt/agent-workspace/.claude/skills + +COPY *.py /app/ + +ENV AGENT_WORKSPACE=/opt/agent-workspace \ + PYTHONUNBUFFERED=1 + +EXPOSE 8000 +CMD ["uvicorn", "service:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/cloudpebble-agent/README.md b/cloudpebble-agent/README.md new file mode 100644 index 0000000..305f28b --- /dev/null +++ b/cloudpebble-agent/README.md @@ -0,0 +1,128 @@ +# cloudpebble-agent + +The agent loop behind the CloudPebble chat panel. One FastAPI container running the +[Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/hosting). It owns no +filesystem, no compiler, no emulator, and no `bash` — every action it takes is an HTTPS +call to CloudPebble or a websocket frame to the user's emulator. + +Design doc: [`../AGENT_PLAN.md`](../AGENT_PLAN.md). + +## Layout + +``` +service.py /turn, /cancel, /health (SSE) +tools.py in-process MCP tools — the agent's only capabilities +cloudpebble_client.py HTTP client for the CloudPebble API +emulator.py qemu websocket: install, screenshot, logs +session_store.py SessionStore → CloudPebble transcript routes +skills/pebble-watchface Forked watchface skill, baked to /opt/agent-workspace/.claude/skills/ +docker-compose.yml one service, 127.0.0.1:8300 → :8000 in the container +deploy_agent.sh rsync + docker compose build/up +``` + +## Deploy + +```bash +./deploy_agent.sh +``` + +Target defaults to `cloudpebble-loop-dev.exe.xyz`; override with `AGENT_HOST` / +`AGENT_SSH_KEY` (or `SSH_KEY`) in the repo-root `.env`. The script rsyncs this directory +to `~/cloudpebble-agent/` on the target and runs `docker compose build && up -d`. + +The container binds **localhost only**. Django on `cloudpebble-dev` reaches it through +nginx + TLS on the loop box; the browser never talks to it. Point the nginx vhost at +`http://127.0.0.1:8300`, then set `AGENT_URL` and the shared secret `AGENT_AUTH_HEADER` in +CloudPebble's settings to match. + +## Credentials + +Everything secret lives in `~/.agent-env` on the target (mode 0600), read by compose via +`env_file: ../.agent-env`. **Nothing is baked into the image** and nothing is committed. + +``` +CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... # required +AGENT_AUTH_HEADER= # required, must match Django's AGENT_AUTH_HEADER +AGENT_MODEL=claude-sonnet-5 +``` + +Generate the shared secret, don't invent one — `/turn` is behind a public TLS vhost and +whoever guesses it spends the Claude Max quota: + +```bash +openssl rand -hex 32 +``` + +Both sides fail closed when it is unset: the service returns 500 and Django refuses to +relay. `deploy_agent.sh` refuses to deploy if `~/.agent-env` is missing or has no +`CLAUDE_CODE_OAUTH_TOKEN` or no `AGENT_AUTH_HEADER`. + +### Rotating the OAuth token + +The token is a long-lived OAuth credential from Eric's Claude Max plan and **expires after +roughly one year**. `claude setup-token` is interactive and needs a browser, so it cannot +be run on the VM: + +```bash +# on a laptop with a browser +claude setup-token # → sk-ant-oat01-... + +# then, on the loop box +ssh cloudpebble-loop-dev.exe.xyz +vi ~/.agent-env # replace CLAUDE_CODE_OAUTH_TOKEN= +cd ~/cloudpebble-agent && docker compose up -d --force-recreate +``` + +**Usage draws from the Claude Max subscription**, not an API key — the same pool as +interactive Claude Code. A screenshot-heavy loop is expensive (images dominate). Two +consequences to keep in mind: + +- Hitting the weekly limit kills a turn mid-flight. It surfaces in chat as "Claude usage + limit reached", distinct from a build error; don't let it retry into the wall. +- `AGENT_MAX_TURNS_PER_DAY` on the Django side is what protects the pool. Keep it low. + +## Logs + +```bash +ssh cloudpebble-loop-dev.exe.xyz +cd ~/cloudpebble-agent +docker compose logs -f agent # live +docker compose logs agent --tail 200 # recent +docker compose ps # status +curl -fsS http://127.0.0.1:8300/health +``` + +## The skill + +`skills/pebble-watchface/` is a fork of +[`coredevices/pebble-watchface-agent-skill`](https://github.com/coredevices/pebble-watchface-agent-skill) +(upstream `.claude/skills/pebble-watchface/`, commit `b91bf6b`, 2026-05-18), baked into the +image at `/opt/agent-workspace/.claude/skills/pebble-watchface/`. The SDK loads it via +`setting_sources=['project']` with `cwd=/opt/agent-workspace`. + +Divergence from upstream, deliberately kept small so improvements can be pulled forward: + +| Change | Why | +|---|---| +| New **CloudPebble Delta** section + a pointer to it at the top | No bash, no filesystem, existing projects only | +| **Build & Test Commands** table replaces the `pebble ...` shell block | `build()` `install()` `screenshot()` `logs()` are tools, not commands | +| `scripts/` not shipped | All five need a shell and a working directory; there is neither. Preview GIFs and app icons are out of scope for v1 | +| Residual `pebble ...` blocks in phases 3-8 rewritten as tool calls | Upstream's build/install/screenshot/logs/publish steps assume the CLI | +| `templates/package.json.template` and `templates/wscript.template` dropped | CloudPebble generates both from project settings; the agent cannot write them | + +`reference/` is byte-identical to upstream; `templates/` keeps only the four source +templates. Everything that makes the +skill worth having is untouched: emery layout math and 2-5px margins, `MINUTE_UNIT` over +`SECOND_UNIT`, fixed-point `sin_lookup`/`cos_lookup`, `layer_get_bounds()` over hardcoded +dimensions, AppMessage callbacks registered before `app_message_open()`, and the visual +verification checklist. + +To pull upstream changes: + +```bash +git remote add watchface-skill https://github.com/coredevices/pebble-watchface-agent-skill.git +git fetch watchface-skill +git diff b91bf6b..watchface-skill/main -- .claude/skills/pebble-watchface/ +``` + +Apply the diff to `skills/pebble-watchface/` and re-apply the three changes above. diff --git a/cloudpebble-agent/agent_loop.py b/cloudpebble-agent/agent_loop.py new file mode 100644 index 0000000..b637441 --- /dev/null +++ b/cloudpebble-agent/agent_loop.py @@ -0,0 +1,416 @@ +"""One turn of the Claude Agent SDK loop. + +Stateless: the transcript is hydrated from CloudPebble at the start of the turn and +mirrored back as it grows. Nothing survives on this box except the baked skill +workspace, which is read-only. + +The agent has no filesystem and no shell -- `tools=['Skill']` removes every other +built-in from its context, and allowed_tools is our eight MCP tools. +""" + +import logging +import os +import re +import tempfile +from contextlib import aclosing + +from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ResultMessage, + SystemMessage, TextBlock, ToolUseBlock, query) + +import project_state +import tools as cp_tools +import vision +from session_store import CloudPebbleSessionStore + +logger = logging.getLogger(__name__) + +WORKSPACE = os.environ.get('AGENT_WORKSPACE', '/opt/agent-workspace') +MODEL = os.environ.get('AGENT_MODEL', 'claude-sonnet-5') + +# Non-Anthropic models via an Anthropic-format gateway. OpenRouter serves one at +# https://openrouter.ai/api (its /v1/messages speaks the Messages API), so the +# bundled Claude Code CLI can drive e.g. moonshotai/kimi-k3 unchanged. Leave +# unset for first-party Anthropic auth (subscription or API key). +# AGENT_API_BASE=https://openrouter.ai/api +# AGENT_API_KEY=sk-or-v1-... +API_BASE = os.environ.get('AGENT_API_BASE', '') +API_KEY = os.environ.get('AGENT_API_KEY', '') + +# Claude Code caps a single response at 32k output tokens by default. Models that +# write a whole watchface in one go blow through that -- deepseek-v4-flash died +# with "Claude's response exceeded the 32000 output token maximum" after 23 +# minutes of work. Raise it, but keep it configurable: a provider that cannot +# serve the larger value will reject the request outright. +MAX_OUTPUT_TOKENS = os.environ.get('AGENT_MAX_OUTPUT_TOKENS', '64000') +# Steps per turn. A watchface is write -> build -> install -> screenshot -> look +# -> fix, several times over, and an unfamiliar runtime costs a handful more: +# a real turn spent its whole 30-step budget working out one import and ended +# mid-experiment. Cheap to raise, expensive to hit. +MAX_TURNS = int(os.environ.get('AGENT_MAX_TURNS', '75')) +SKILLS = os.environ.get('AGENT_SKILLS', 'all') + +# Belt and braces: `tools` already removes these from the model's context. +BANNED_TOOLS = ['Bash', 'BashOutput', 'KillShell', 'Read', 'Write', 'Edit', 'MultiEdit', + 'NotebookEdit', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'Task'] + +# Appended to Claude Code's own preset prompt, so everything the CLI relies on +# stays intact. +# +# Screenshots are the whole point of this loop: the user is watching a chat panel +# that renders every one, and a watchface is a visual artifact -- a turn that ends +# in prose is asking them to take the result on faith. Models are frugal with +# screenshots by default (each one is another tool round-trip), so this says +# plainly that they are cheap here and that the last word is an image. +SYSTEM_PROMPT_APPEND = """ +## Where you are + +You are inside CloudPebble, a browser IDE for Pebble smartwatch apps, working on +ONE project for a user who is watching a chat panel next to their editor. Most of +them cannot write code and are describing what they want in plain language. + +You have no filesystem, no shell and no network of your own. Every action you can +take is one of the tools below. Do not reach for Bash, Read, Write, Edit, Glob, +Grep, WebFetch, WebSearch or Task -- they are not here, and guessing at them +wastes a step. There is no `run` skill and no way to read a file by naming it to +the Skill tool. + +## Everything you can do + +Project state +- `list_files()` -- the whole live state: settings, target platforms and their + screen sizes, source files, resources, and which watch the emulator is running. + The same block is appended to every message you receive, so you usually already + have it; call this after you change settings or when in doubt. + +Code +- `read_file(path)` / `write_file(path, content)` / `delete_file(path)` -- source + files, addressed by project path (`src/c/main.c`, `src/pkjs/index.js`, + `src/embeddedjs/main.js`). Always write the whole file. +- `write_binary_file(path, content_base64)` -- Alloy assets under + `src/embeddedjs` (.png, .pdc, .ttf). + +Resources (images, fonts, blobs the app loads by resource id) +- `write_resource(file_name, kind, content_base64, resource_ids)` -- kinds are + png, png-trans, bitmap, pbi, font, raw. Replacing keeps the existing ids, so + new artwork stays hooked up to the code that draws it. +- `delete_resource(file_name)` + +Settings -- all of these are settings, not files. There is no package.json and no +wscript in this IDE; do not go looking for them, and do not ask the user to change +something you can change yourself. +- `set_app_settings(...)`: `app_is_watchface` (REQUIRED true for a watchface, or + it installs as an app and never appears as a face), `app_platforms`, `app_uuid`, + `app_keys` (AppMessage keys), `app_capabilities` (location, health, + configurable), `app_dependencies` (npm packages, e.g. + {"@moddable/pebbleproxy": "^0.1.3"}), `app_long_name`, `app_short_name`, + `app_company_name`, `app_version_label`, `menu_icon`, `app_is_hidden`, + `app_is_shown_on_communication`, `app_modern_multi_js`, `name`. + Only the fields you pass change. + +Build and run +- `build()` -- compiles on the CloudPebble build farm, returns the full log. A + failed compile is a normal result: read the log and fix the code. +- `install()` -- installs the last successful build into the emulator. +- `screenshot()` -- captures the emulator screen. +- `press(button, hold_ms)` -- up, select, down, back, or shake. This is how you + drive an app: open a menu, scroll a list, play a turn of a game, come back with + back. Screenshot after each press to see what it did. A watchface has no + buttons at all, so shake is the only input it can take. +- `logs(seconds)` -- drains app logs from the watch and console.log from the + phone-side JS. + +You cannot touch the screen. Emery and gabbro have touchscreens, but touch +reaches the emulator over VNC rather than the control channel these tools use. +Say so if an app needs it. + +Documentation +- `read_reference(name)` -- the skill's own guides and templates, e.g. + `reference/alloy-guide.md`, `reference/watchapp-guide.md`, + `reference/pebble-api-reference.md`, `templates/animated-watchface.c`. Call it + with no name to list everything available. Read the guide BEFORE guessing at an + API in a runtime you do not know -- one turn once spent its entire step budget + discovering by trial what the Alloy guide says in a sentence. +- `Skill('pebble-watchface')` -- the watchface/watchapp workflow. Use + `read_reference` for its reference files; the Skill tool cannot read them. + +## The emulator is not yours + +It runs in the user's browser tab, not on your machine. You cannot start it, stop +it or reboot it. If a tool tells you there is no emulator, or that the emulator +rejected an install, that is a fact about their browser and NOT a fault in your +code: say so and carry on with what does not need it. Never start deleting +working features to bisect an emulator problem. The panel offers them a button to +open or restart it, and they will tell you when it is back. + +## Screenshots + +You are building something the user looks at, in a chat panel that displays every +screenshot you take. Screenshots are the only evidence either of you has that the +app actually works. They are cheap -- take more of them than feels necessary. + +- After every `install()`, call `screenshot()`. No exceptions. +- Screenshot again after each visual change, so the user can see the difference + rather than read about it. +- For anything animated or time-driven, take several spaced out, and say what + changed between them. +- ALWAYS finish with a fresh `screenshot()` of the final, working result -- taken + after the last build and install, not reused from earlier. Then describe what + it shows. A turn that ends without one is incomplete, even if everything built. +- An interactive app is not verified until you have driven it: `press()` through + the flow the user described -- open it, scroll it, play it -- screenshotting as + you go. "It builds" is not "it works". +- One frame cannot show an animation. If you claim something moves, prove it: + several screenshots spaced out, and say what changed between them. Asked to do + this once, a turn discovered the animation it had just reported working was not + running at all. +- Never describe a screen you did not see. + +## Watchface or app + +`app_is_watchface` decides where the thing lives: true replaces the user's clock, +false puts it in the app menu they open. Set it deliberately, and set it AGAIN +whenever the user corrects you about what they are building -- rewriting the code +as an app while leaving the flag true ships something that still takes over their +watchface. + +## Finishing + +The user cannot read code. End with what changed in their terms, what it looks +like on screen, and anything you could not do and why. Do not degrade the app to +make a screenshot look better -- ship what they would actually use. +""" + +USAGE_LIMIT_HINTS = ('usage limit', 'rate limit', 'quota') + +# A credential that has expired, been revoked or was mistyped. Distinguished from +# a usage limit because the user has to act: re-authenticate rather than wait. +AUTH_HINTS = ('authentication_error', 'authentication_failed', 'failed to authenticate', + 'invalid api key', 'invalid_api_key', 'invalid x-api-key', + 'unauthorized', 'not authenticated', 'expired token', 'token expired', + 'invalid bearer', 'oauth token', 'user not found', + '401', '403 forbidden', 'permission_error') + + +# One config dir per SESSION, not per turn. +# +# This used to be tempfile.mkdtemp() per turn, deleted afterwards. That broke +# prompt caching on every provider that caches by prefix rather than by explicit +# cache_control breakpoints: DeepSeek's docs are explicit that only requests with +# identical prefixes from the 0th token hit, and that "a changing timestamp, +# request ID, or user-specific line at the top can destroy useful prefix reuse". +# A fresh random path each turn is exactly that. It also threw away the CLI's +# local session state, forcing a full rebuild every turn. +# +# Isolation is unchanged: a session belongs to one user and one project, so a +# per-session directory is no more shared than a per-turn one was. +CONFIG_ROOT = os.environ.get('AGENT_CONFIG_ROOT', '/tmp/agent-sessions') + + +def _session_config_dir(session_key): + safe = re.sub(r'[^A-Za-z0-9_-]', '_', str(session_key))[:64] + path = os.path.join(CONFIG_ROOT, 'session-%s' % safe) + os.makedirs(path, exist_ok=True) + return path + + +def _subprocess_env(config_dir, provider=None): + """Environment for the CLI subprocess, for this turn's credentials. + + A per-request provider means one user's key never leaks into another's turn: + the environment is built fresh each time and nothing is read from the + container's own credentials unless no provider was supplied. + """ + env = {'CLAUDE_CONFIG_DIR': config_dir, + 'CLAUDE_CODE_DISABLE_AUTO_MEMORY': '1'} + if MAX_OUTPUT_TOKENS: + env['CLAUDE_CODE_MAX_OUTPUT_TOKENS'] = str(MAX_OUTPUT_TOKENS) + + base = (provider or {}).get('api_base') if provider else API_BASE + key = (provider or {}).get('api_key') if provider else API_KEY + kind = (provider or {}).get('secret_kind', 'api_key') if provider else 'api_key' + + # Start from a clean slate every turn so a previous configuration cannot + # bleed through: whichever of these is unset must be explicitly empty. + env['ANTHROPIC_BASE_URL'] = base or '' + env['ANTHROPIC_AUTH_TOKEN'] = '' + env['ANTHROPIC_API_KEY'] = '' + env['CLAUDE_CODE_OAUTH_TOKEN'] = '' + + if base: + # Third-party gateway: bearer auth only. A subscription OAuth token must + # never be sent to someone else's endpoint. + env['ANTHROPIC_AUTH_TOKEN'] = key or '' + elif kind == 'oauth': + # First-party Anthropic on the user's own Claude plan. + env['CLAUDE_CODE_OAUTH_TOKEN'] = key or '' + elif key: + env['ANTHROPIC_API_KEY'] = key + else: + # No per-turn credential: fall back to whatever the container holds. + env.pop('CLAUDE_CODE_OAUTH_TOKEN') + env.pop('ANTHROPIC_API_KEY') + return env + + +def _kind(text): + low = (text or '').lower() + # Order matters: a quota message often mentions 429 alongside auth-ish words, + # and telling a user to re-authenticate when they merely ran out is worse + # than telling them to wait. + if any(h in low for h in USAGE_LIMIT_HINTS): + return 'usage_limit' + if any(h in low for h in AUTH_HINTS): + return 'auth' + return 'error' + + +def _state_suffix(ctx, emulator): + """The project's live state, appended to the user's message. + + Fetched per turn: the agent otherwise starts blind and guesses at the target + platform, the screen size and whether the project is even flagged as a + watchface. A failure here is not worth losing the turn over -- the tools can + still answer all of it -- so it degrades to nothing. + """ + try: + info = ctx.cp.info() + except Exception: + logger.warning('could not read project state for the turn', exc_info=True) + return '' + block = project_state.render(info, emulator) + return ('\n\n' + block) if block else '' + + +async def run_turn(*, project_id, cp_token, cp_base_url, message, emulator=None, + sdk_session_id=None, session_key=None, provider=None, cancel=None): + """Yield SSE envelope dicts: {seq, role, type, data}.""" + ctx = cp_tools.Context(cp_base_url, cp_token, project_id, emulator) + if provider: + # Whether this turn's model can see, and who looks for it when it cannot. + ctx.model_vision = bool(provider.get('model_vision', True)) + ctx.vision_config = provider.get('vision') + config_dir = _session_config_dir(session_key or project_id) + + options = ClaudeAgentOptions( + resume=sdk_session_id or None, + system_prompt={'type': 'preset', 'preset': 'claude_code', + 'append': SYSTEM_PROMPT_APPEND}, + session_store=CloudPebbleSessionStore(cp_base_url, cp_token), + cwd=WORKSPACE, + setting_sources=['project'], + skills=SKILLS if SKILLS == 'all' else [s for s in SKILLS.split(',') if s], + tools=['Skill'], + allowed_tools=list(cp_tools.ALLOWED_TOOLS), + disallowed_tools=BANNED_TOOLS, + permission_mode='dontAsk', + mcp_servers={cp_tools.SERVER_NAME: cp_tools.build_server(ctx)}, + strict_mcp_config=True, + max_turns=MAX_TURNS, + model=(provider or {}).get('model') or MODEL, + env=_subprocess_env(config_dir, provider), + stderr=lambda line: logger.debug('cli: %s', line), + ) + + state = {'seq': 0} + + def ev(role, type_, data): + state['seq'] += 1 + return {'seq': state['seq'], 'role': role, 'type': type_, 'data': data} + + def drain(): + out, ctx.events = ctx.events, [] + return [ev(e['role'], e['type'], e['data']) for e in out] + + session_id, turn_count = sdk_session_id, 0 + usage = {} + # What has already been said to the user, so the same failure is not reported + # twice in two different vocabularies. + reported = set() + prompt = message + _state_suffix(ctx, emulator) + try: + async with aclosing(query(prompt=prompt, options=options)) as stream: + async for msg in stream: + for e in drain(): + yield e + + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock) and block.text.strip(): + yield ev('assistant', 'text', {'text': block.text}) + elif isinstance(block, ToolUseBlock): + name = block.name.split('__')[-1] + yield ev('tool', 'tool_use', {'tool': name, 'args': block.input}) + if msg.error: + yield ev('system', 'error', {'message': str(msg.error), + 'kind': _kind(str(msg.error))}) + elif isinstance(msg, SystemMessage) and msg.subtype == 'mirror_error': + # Non-fatal, but resume will silently lose these entries. + logger.error('transcript mirror failed: %s', getattr(msg, 'error', msg.data)) + yield ev('system', 'error', {'message': 'transcript mirror failed', + 'kind': 'mirror_error'}) + elif isinstance(msg, ResultMessage): + session_id, turn_count = msg.session_id, msg.num_turns + # Token accounting, for comparing models and for cost caps. + raw = getattr(msg, 'usage', None) or {} + usage = { + 'input_tokens': raw.get('input_tokens'), + 'output_tokens': raw.get('output_tokens'), + 'cache_read_input_tokens': raw.get('cache_read_input_tokens'), + 'cache_creation_input_tokens': raw.get('cache_creation_input_tokens'), + 'total_cost_usd': getattr(msg, 'total_cost_usd', None), + 'duration_ms': getattr(msg, 'duration_ms', None), + # The model and endpoint THIS turn ran on, not the + # container's defaults. Those defaults are whatever the + # last bench left behind, so reporting them made a turn on + # the user's own Claude plan read as 'deepseek-v4-flash' + # against api.deepseek.com -- which is exactly the sort of + # thing you go looking at when a screenshot seems ignored. + 'model': (provider or {}).get('model') or MODEL, + # Record the whole configuration, not just the main model: + # a bench result is meaningless without knowing which eye + # the agent was using and whether it could see at all. + 'api_base': (provider.get('api_base') or 'anthropic') if provider + else (API_BASE or 'anthropic'), + 'model_vision': ctx.model_vision, + 'vision_model': ((ctx.vision_config or {}).get('model') + if vision.configured(ctx.vision_config) else None), + 'provider': (provider or {}).get('provider', 'container'), + 'max_output_tokens': MAX_OUTPUT_TOKENS, + 'vision_calls': ctx.vision_calls, + 'vision_cost_usd': round(ctx.vision_cost_usd, 6), + } + if msg.is_error: + text = msg.result or msg.subtype + if msg.subtype == 'error_max_turns': + reported.add('max_turns') + # The raw subtype is a machine token, and a user shown + # "error_max_turns" has no idea their work survived. + # It did: the transcript is mirrored and the next + # message resumes from where this stopped. + text = ('I hit the step limit for this turn. Everything ' + 'so far is saved -- send "continue" and I will ' + 'carry on from here.') + yield ev('system', 'error', {'message': text, 'kind': _kind(text)}) + + if cancel is not None and cancel.is_set(): + yield ev('system', 'error', {'message': 'cancelled', 'kind': 'cancelled'}) + break + + for e in drain(): + yield e + except Exception as e: + logger.exception('turn failed') + # The SDK raises after the result message it already described, so a step + # limit was reported in the user's own words a moment ago: saying + # "Reached maximum number of turns (75)" straight after it is noise. + if not ('max_turns' in reported and 'maximum number of turns' in str(e)): + yield ev('system', 'error', {'message': str(e), 'kind': _kind(str(e))}) + finally: + # Deliberately NOT removed: the directory is per session and holds the + # CLI's own transcript, which the next turn resumes from. Deleting it + # forces a full rebuild and a cold prompt cache. Sessions are cleaned up + # when the container recycles. + pass + + yield ev('system', 'done', {'turn_count': turn_count, 'sdk_session_id': session_id, + 'usage': usage}) diff --git a/cloudpebble-agent/cloudpebble_client.py b/cloudpebble-agent/cloudpebble_client.py new file mode 100644 index 0000000..6d02b98 --- /dev/null +++ b/cloudpebble-agent/cloudpebble_client.py @@ -0,0 +1,367 @@ +"""HTTP client for the CloudPebble API, authorised by a scoped agent bearer token. + +Only the endpoints the token is accepted by (see utils/agent_token.py) are here: +project info, source load/save/create/delete, build run/last/info/log/download. + +Everything that can fail on the wire raises CloudPebbleError, never a bare +requests exception -- tools.py turns CloudPebbleError into a tool result the model can +read and react to, and anything else kills the whole turn. +""" + +import json +import logging +import os +import re +import time + +import requests + +logger = logging.getLogger(__name__) + +TIMEOUT = 60 +BUILD_POLL_INTERVAL = 2 +BUILD_TIMEOUT = 600 +# Consecutive poll failures before we stop waiting. Rides out a 502; does not spin for +# ten minutes on an expired token. +BUILD_POLL_MISSES = 10 + +STATE_WAITING, STATE_FAILED, STATE_SUCCEEDED = 1, 2, 3 + +# Where each kind of source file lives, mirroring SourceFile.DIR_MAP server-side. +# +# CloudPebble stores a file as (target, name-relative-to-that-target's-dir), and +# validates the two together: a name of 'src/pkjs/index.js' with target 'app' +# becomes 'src/c/src/pkjs/index.js' and is refused with "Unacceptable file +# extension for app file in [src/c/...]". Sending target='app' for everything is +# why writing pkjs used to fail three times and then get given up on. The model +# should keep passing whole project paths -- it is this client's job to split +# them the way the server expects. +# +# Order matters: the longest prefixes must be tried first, or 'src' swallows +# 'src/pkjs'. +DIR_MAP = { + 'native': [('src/pkjs', 'pkjs'), ('src/js', 'pkjs'), ('worker_src/c', 'worker'), + ('worker_src', 'worker'), ('src/c', 'app'), ('src', 'app')], + 'alloy': [('src/pkjs', 'pkjs'), ('src/embeddedjs', 'embeddedjs'), + ('src/c', 'app'), ('src', 'app')], + 'package': [('src/c', 'app'), ('include', 'public'), ('src/js', 'pkjs')], + 'rocky': [('src/rocky', 'app'), ('src/pkjs', 'pkjs'), ('src/common', 'common')], + 'pebblejs': [('src/js', 'app')], + 'simplyjs': [('src', 'app')], +} + +# Text extensions CloudPebble accepts; anything else has to go through the +# binary upload endpoint instead of the source-file one. +TEXT_EXTENSIONS = ('.c', '.h', '.js', '.json') + + +def _default_identifier(file_name): + """IMAGE_MY_THING from my-thing.png, the same shape the IDE suggests.""" + stem = os.path.splitext(os.path.basename(file_name))[0] + return re.sub(r'[^A-Za-z0-9]+', '_', stem).strip('_').upper() or 'RESOURCE' + + +def split_path(project_type, path): + """('src/pkjs/index.js', native) -> ('pkjs', 'index.js'). + + A bare name with no directory keeps the caller's default target, which is how + 'main.c' still works. + """ + path = path.replace('\\', '/').lstrip('/') + for prefix, target in DIR_MAP.get(project_type or 'native', DIR_MAP['native']): + if path.startswith(prefix + '/'): + return target, path[len(prefix) + 1:] + return None, path + + +class CloudPebbleError(Exception): + pass + + +class CloudPebbleClient(object): + def __init__(self, base_url, token, project_id): + self.base = base_url.rstrip('/') + self.project_id = int(project_id) + self.session = requests.Session() + self.session.headers['Authorization'] = 'Bearer %s' % token + self._project_type = None + + # -- plumbing ---------------------------------------------------------- + + def _url(self, path): + return '%s/ide/%s' % (self.base, path.lstrip('/')) + + def _request(self, method, path, **kwargs): + """One place where a transport failure becomes a CloudPebbleError.""" + kwargs.setdefault('timeout', TIMEOUT) + try: + return self.session.request(method, self._url(path), **kwargs) + except requests.RequestException as e: + raise CloudPebbleError('%s %s failed: %s' % (method, path, e)) + + def _json(self, method, path, **kwargs): + r = self._request(method, path, **kwargs) + try: + body = r.json() + except ValueError: + raise CloudPebbleError('%s %s returned %d (not JSON)' % (method, path, r.status_code)) + if r.status_code >= 400 or body.get('success') is False: + raise CloudPebbleError(body.get('error') or 'HTTP %d' % r.status_code) + return body + + def _project(self, path): + return 'project/%d/%s' % (self.project_id, path) + + # -- files ------------------------------------------------------------- + + def info(self): + info = self._json('GET', self._project('info')) + # Which DIR_MAP applies. Set at project creation and never changed, so + # one read is enough for the turn. + self._project_type = info.get('type') or 'native' + return info + + @property + def project_type(self): + if self._project_type is None: + self.info() + return self._project_type + + def list_files(self): + return self.info()['source_files'] + + def live_emulator(self, platform=None): + """The user's emulator as it is right now, or None. + + A turn is handed one descriptor at the start; rebooting the emulator + mid-turn (which CloudPebble tells the user to do when an install is + rejected) makes that descriptor point at something dead for the rest of + the turn. This is how the tools pick up the replacement. + """ + path = self._project('agent/emulator') + if platform: + path += '?platform=%s' % platform + try: + return self._json('GET', path).get('emulator') + except CloudPebbleError: + return None + + # Project settings. Scoped by the token to this one project; the endpoint + # refuses anything that reaches another project or an external service. + SETTINGS_FIELDS = ( + 'name', 'app_company_name', 'app_short_name', 'app_long_name', + 'app_version_label', 'app_capabilities', 'app_keys', 'app_platforms', + 'app_uuid', 'menu_icon', 'app_dependencies', + 'app_is_watchface', 'app_is_hidden', 'app_is_shown_on_communication', + 'app_modern_multi_js', + ) + _BOOL_FIELDS = ('app_is_watchface', 'app_is_hidden', + 'app_is_shown_on_communication', 'app_modern_multi_js') + + def set_app_settings(self, **fields): + data = {} + for key, value in fields.items(): + if value is None: + continue + if key not in self.SETTINGS_FIELDS: + raise CloudPebbleError('unknown setting: %s' % key) + if key in self._BOOL_FIELDS: + data[key] = 'true' if value else 'false' + else: + data[key] = str(value) + if not data: + raise CloudPebbleError('no settings given') + return self._json('POST', self._project('agent/app_settings'), data=data) + + def _find(self, path): + for f in self.list_files(): + if path in (f['name'], f['file_path']): + return f + return None + + def read_file(self, path): + f = self._find(path) + if f is None: + raise CloudPebbleError('no such file: %s' % path) + return self._json('GET', self._project('source/%d/load' % f['id']))['source'] + + def write_file(self, path, content, target=None): + """Returns (file_id, old_content). old_content is None for a new file.""" + f = self._find(path) + if f is None: + guessed, name = split_path(self.project_type, path) + target = target or guessed or 'app' + if not name.endswith(TEXT_EXTENSIONS) and target != 'embeddedjs': + raise CloudPebbleError( + '%s is not a text source file. Use write_binary_file for images, ' + 'fonts and other binary assets, or add it as a resource.' % path) + created = self._json('POST', self._project('create_source_file'), + data={'name': name, 'target': target, 'content': content}) + return created['file']['id'], None + old = self._json('GET', self._project('source/%d/load' % f['id']))['source'] + # `modified` is the server's own timestamp for the file: if someone saved + # in between, Django refuses rather than clobbering. + self._json('POST', self._project('source/%d/save' % f['id']), + data={'content': content, 'folded_lines': '[]', + 'modified': int(f['lastModified'])}) + return f['id'], old + + def delete_file(self, path): + f = self._find(path) + if f is None: + raise CloudPebbleError('no such file: %s' % path) + self._json('POST', self._project('source/%d/delete' % f['id'])) + return f['id'] + + def write_binary_file(self, path, data): + """A non-text source file: an Alloy asset (.png, .pdc, .ttf) under src/embeddedjs. + + There is no binary save, only create, so replacing means deleting first. + """ + guessed, name = split_path(self.project_type, path) + target = guessed or 'embeddedjs' + # Binary *source* files exist only under src/embeddedjs, and only Alloy + # projects have that. Everywhere else an image or font is a resource, and + # saying so beats letting the server answer "Unacceptable file extension + # for app file in [src/c/embeddedjs/x.png]". + if target != 'embeddedjs' or self.project_type != 'alloy': + raise CloudPebbleError( + 'binary source files only exist under src/embeddedjs in an Alloy ' + 'project (this is a %s project). An image or font belongs in ' + 'resources -- use write_resource.' % self.project_type) + existing = self._find(path) + if existing is not None: + self._json('POST', self._project('source/%d/delete' % existing['id'])) + created = self._json('POST', self._project('create_binary_source_file'), + data={'name': name, 'target': target}, + files={'file': (name.split('/')[-1], data, + 'application/octet-stream')}) + return created['file']['id'], existing is not None + + def read_binary_file(self, path): + f = self._find(path) + if f is None: + raise CloudPebbleError('no such file: %s' % path) + r = self._request('GET', self._project('source/%d/download' % f['id'])) + if r.status_code >= 400: + raise CloudPebbleError('could not download %s: HTTP %d' % (path, r.status_code)) + return r.content + + # -- resources --------------------------------------------------------- + # + # Images, fonts and raw blobs: everything the IDE's Resources pane does, + # except renaming and per-variant deletes, which nothing has needed. + + RESOURCE_KINDS = ('bitmap', 'png', 'png-trans', 'font', 'pbi', 'raw') + + def _find_resource(self, name): + for r in self.info().get('resources') or []: + if r.get('file_name') == name: + return r + return None + + def write_resource(self, file_name, kind, data, resource_ids=None): + """Create a resource, or replace the file of one that exists. + + resource_ids are the identifiers C code refers to (RESOURCE_ID_), each + optionally carrying its own font/bitmap options. Replacing keeps the + existing identifiers unless new ones are given -- changing the artwork + should not silently unhook it from the code that draws it. + """ + if kind not in self.RESOURCE_KINDS: + raise CloudPebbleError('unknown resource kind %r (expected one of %s)' + % (kind, ', '.join(self.RESOURCE_KINDS))) + existing = self._find_resource(file_name) + upload = {'file': (file_name.split('/')[-1], data, 'application/octet-stream')} + if existing is None: + ids = resource_ids or [{'id': _default_identifier(file_name)}] + created = self._json('POST', self._project('create_resource'), + data={'kind': kind, 'file_name': file_name, + 'resource_ids': json.dumps(ids), + 'new_tags': json.dumps([])}, + files=upload) + return created['file'], False + ids = resource_ids + if ids is None: + # get_options_dict shape, back the way it came -- minus the nulls. + # The server reads options by presence, so an unset 'tracking': None + # comes back as int(None) and 500s the whole update. + ids = [dict({k: v for k, v in (extra or {}).items() if v is not None}, id=rid) + for rid, extra in (existing.get('extra') or {}).items()] or \ + [{'id': rid} for rid in existing.get('identifiers') or []] + updated = self._json('POST', self._project('resource/%d/update' % existing['id']), + data={'resource_ids': json.dumps(ids), + 'file_name': file_name, + 'replacements': json.dumps([['', 0]])}, + files={'replacement_files[]': upload['file']}) + return updated['file'], True + + def delete_resource(self, file_name): + existing = self._find_resource(file_name) + if existing is None: + raise CloudPebbleError('no such resource: %s' % file_name) + self._json('POST', self._project('resource/%d/delete' % existing['id'])) + return existing['id'] + + # No read_resource: show_resource 302s to a storage URL that is only + # resolvable from inside the CloudPebble network (on dev it is literally + # http://s3:4569/), so the agent cannot follow it. Nothing needs it -- the + # agent writes assets, it does not read them back. + + # -- builds ------------------------------------------------------------ + + def build_info(self, build_id): + # ide/urls.py: project//build//info -> get_build_info + return self._json('GET', self._project('build/%d/info' % build_id)).get('build') or {} + + def build(self): + """Run a build to completion. Returns (state, build_id, log), where state is + 'succeeded', 'failed' or 'unknown'. + + A failed compile is a normal result, not an exception -- the model is + expected to read the log and fix its code. Likewise a transient 502 while + polling: keep polling until the deadline rather than killing the turn. + + Polls this specific build rather than build/last, which returns the project's + newest build and would report someone else's compile. + """ + started = self._json('POST', self._project('build/run')) + build_id = started['build_id'] + + state, misses = None, 0 + deadline = time.time() + BUILD_TIMEOUT + while time.time() < deadline: + time.sleep(BUILD_POLL_INTERVAL) + try: + state = self.build_info(build_id).get('state') + except CloudPebbleError as e: + # A 502 or a blip is worth retrying; an expired token is not, and + # spinning on it for the full BUILD_TIMEOUT would stall the turn. + misses += 1 + logger.info('build poll hiccup %d/%d: %s', misses, BUILD_POLL_MISSES, e) + if misses >= BUILD_POLL_MISSES: + return 'unknown', build_id, 'lost contact with the build: %s' % e + continue + misses = 0 + if state in (STATE_FAILED, STATE_SUCCEEDED): + break + else: + return 'unknown', build_id, 'build did not finish within %ds' % BUILD_TIMEOUT + + try: + log = self._json('GET', self._project('build/%d/log' % build_id)).get('log', '') + except CloudPebbleError as e: + log = '(could not read the build log: %s)' % e + return ('succeeded' if state == STATE_SUCCEEDED else 'failed'), build_id, log + + def last_build(self): + return self._json('GET', self._project('build/last')).get('build') + + def download_pbw(self, build_id): + # 'package' projects ship a tarball, everything else a pbw. v1 is watchfaces + # only, but hardcoding the name here would 404 silently outside that. + name = 'package.tar.gz' if self.info().get('type') == 'package' else 'watchface.pbw' + r = self._request('GET', self._project('build/%d/download/%s' % (build_id, name))) + if r.status_code != 200: + raise CloudPebbleError('could not download build %d (HTTP %d)' % (build_id, r.status_code)) + return r.content diff --git a/cloudpebble-agent/deploy_agent.sh b/cloudpebble-agent/deploy_agent.sh new file mode 100755 index 0000000..1ef023f --- /dev/null +++ b/cloudpebble-agent/deploy_agent.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +# Agent loop deployment script +# Syncs cloudpebble-agent/ to the loop VM and restarts the service. +# The Claude OAuth token lives only in ~/.agent-env on the target — never in the image. +# +# Usage: ./deploy_agent.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Load .env from the repo root (same file deploy_qemu.sh reads) +if [ -f "$SCRIPT_DIR/../.env" ]; then + set -a + source "$SCRIPT_DIR/../.env" + set +a +fi + +AGENT_HOST="${AGENT_HOST:-cloudpebble-loop-dev.exe.xyz}" +SSH_KEY="${AGENT_SSH_KEY:-${SSH_KEY:-$HOME/.ssh/id_exe}}" +SSH="ssh -i $SSH_KEY $AGENT_HOST" + +echo "==> Checking ~/.agent-env on $AGENT_HOST..." +$SSH 'test -f ~/.agent-env || { echo "MISSING ~/.agent-env — see README (claude setup-token)"; exit 1; } + grep -q CLAUDE_CODE_OAUTH_TOKEN ~/.agent-env || { echo "~/.agent-env has no CLAUDE_CODE_OAUTH_TOKEN"; exit 1; } + grep -q AGENT_AUTH_HEADER ~/.agent-env || { + echo "~/.agent-env has no AGENT_AUTH_HEADER — the service will refuse every request."; + echo "Generate one with: openssl rand -hex 32"; + echo "and set the same value as AGENT_AUTH_HEADER in CloudPebble'"'"'s .env."; + exit 1; }' + +echo "==> Syncing code to $AGENT_HOST..." +rsync -avz --delete \ + --exclude='.git' \ + --exclude='__pycache__' \ + --exclude='.DS_Store' \ + -e "ssh -i $SSH_KEY" \ + "$SCRIPT_DIR/" "$AGENT_HOST":~/cloudpebble-agent/ + +echo "==> Building and restarting agent service..." +$SSH "cd ~/cloudpebble-agent && docker compose build && docker compose up -d" + +echo "==> Container status:" +$SSH "cd ~/cloudpebble-agent && docker compose ps" + +echo "" +echo "==> Health check:" +$SSH "curl -fsS --max-time 10 http://127.0.0.1:8300/health" || echo "(not healthy yet — docker compose logs -f agent)" +echo "" diff --git a/cloudpebble-agent/docker-compose.yml b/cloudpebble-agent/docker-compose.yml new file mode 100644 index 0000000..c2efc66 --- /dev/null +++ b/cloudpebble-agent/docker-compose.yml @@ -0,0 +1,21 @@ +services: + agent: + build: . + # Bound to all interfaces so the exe.dev HTTPS proxy (which forwards this + # VM's port 8000) can reach it: Django runs on a DIFFERENT box, and exe.dev + # gates SSH per-account so there is no VM-to-VM tunnel available. + # + # ponytail: dev-only exposure. The proxy must be `share set-public`, so the + # only thing standing in front of /turn is the AGENT_AUTH_HEADER secret. + # Before this serves anyone but us, invert the connection: have the agent + # box long-poll Django for pending turns (outbound-only, zero inbound + # surface) instead of Django calling in. See AGENT_PLAN.md. + ports: + - "8000:8000" + # ~/.agent-env on the deploy target. Compose resolves this relative to the + # compose file, which deploy_agent.sh puts at ~/cloudpebble-agent/. + # Compose does not expand ~, so this must stay a relative path. + env_file: + - ../.agent-env + mem_limit: 4g + restart: unless-stopped diff --git a/cloudpebble-agent/emulator.py b/cloudpebble-agent/emulator.py new file mode 100644 index 0000000..2924748 --- /dev/null +++ b/cloudpebble-agent/emulator.py @@ -0,0 +1,403 @@ +"""Drive a CloudPebble qemu emulator over its phone websocket. + +Ported from the verified phase-0 spike (_spike_emulator_VERIFIED.py). The framing +below is reverse-engineered from ide/static/ide/js/libpebble/{proxysocket,libpebble}.js +and was proven end to end against cloudpebble-dev over public wss. + +Three gotchas, kept deliberately: + 1. /qemu/launch returns before pypkjs is listening. We never launch -- the browser + did that -- so instead of sleeping we retry the attach for BOOT_TIMEOUT. + 2. Don't re-dial an emulator that already had a client if you can avoid it: it can + hang at the auth frame with no error. Hence the process-wide _CONNS registry -- + we attach once and reuse. A connection that dies is evicted and re-attached + exactly once; an attach that *fails* is remembered for FAILURE_TTL so a model + calling install/screenshot/logs in a row doesn't spend minutes re-dialling. + 3. Inbound frames are 0x00. 0x01 is outbound only. +""" + +import logging +import os +import struct +import threading +import time +import zlib + +import websocket + +logger = logging.getLogger(__name__) + +OP_FROM_WATCH = 0x00 +OP_TO_WATCH = 0x01 +OP_PHONE_LOG = 0x02 +OP_INSTALL = 0x04 +OP_INSTALL_STATUS = 0x05 +OP_CONNECTION = 0x08 +OP_AUTH = 0x09 +# QEMU control channel: 0x0b . Same socket as everything else +# (libpebble.js:send_qemu_command). +OP_QEMU = 0x0b + +QEMU_TAP = 2 +QEMU_BUTTON = 8 + +# Button *bits*, as libpebble2's QemuButton.Button enumerates them. The payload is +# the set of buttons currently held, so a press is "bit set", a release is 0. +BUTTONS = {'back': 1, 'up': 2, 'select': 4, 'down': 8} + +# Accelerometer axes for a tap, matching pebble's own x/y/z ordering. +TAP_AXES = {'x': 0, 'y': 1, 'z': 2} + +ENDPOINT_LOGS = 2000 +ENDPOINT_APP_LOGS = 2006 +ENDPOINT_APP_MANAGER = 6000 +ENDPOINT_SCREENSHOT = 8000 +ENDPOINT_PUTBYTES = 48879 + +# How long to keep retrying the attach. pypkjs can bind its port ~10s after the +# browser's /qemu/launch returns, so a user who clicks Launch and immediately sends a +# message needs more than one attempt -- hence a connect timeout well inside this. +BOOT_TIMEOUT = int(os.environ.get('AGENT_EMULATOR_BOOT_TIMEOUT', '60')) +CONNECT_TIMEOUT = 10 +RETRY_DELAY = 3 +# How long a failed attach is remembered, so the next tool call fails in milliseconds +# instead of re-dialling. Short enough that launching an emulator mid-turn recovers. +FAILURE_TTL = 60 + +LOG_LEVELS = {0: 'ERROR', 1: 'ERROR', 50: 'WARN', 100: 'INFO', 200: 'DEBUG', 250: 'VERBOSE'} + +# The panel-corrected palette the IDE renders these frames through +# (libpebble.js:decode_image_8bit_corrected). The naive 2-bits-per-channel x85 map is a +# different picture -- index 0x30 is pure red there and a muted maroon here -- and the +# whole point of screenshot() is the model judging colours the user will actually see. +COLOUR_MAP = ( + 0x000000, 0x001e41, 0x004387, 0x0068ca, 0x2b4a2c, 0x27514f, 0x16638d, 0x007dce, + 0x5e9860, 0x5c9b72, 0x57a5a2, 0x4cb4db, 0x8ee391, 0x8ee69e, 0x8aebc0, 0x84f5f1, + 0x4a161b, 0x482748, 0x40488a, 0x2f6bcc, 0x564e36, 0x545454, 0x4f6790, 0x4180d0, + 0x759a64, 0x759d76, 0x71a6a4, 0x69b5dd, 0x9ee594, 0x9de7a0, 0x9becc2, 0x95f6f2, + 0x99353f, 0x983e5a, 0x955694, 0x8f74d2, 0x9d5b4d, 0x9d6064, 0x9a7099, 0x9587d5, + 0xafa072, 0xaea382, 0xababab, 0xa7bae2, 0xc9e89d, 0xc9eaa7, 0xc7f0c8, 0xc3f9f7, + 0xe35462, 0xe25874, 0xe16aa3, 0xde83dc, 0xe66e6b, 0xe6727c, 0xe37fa7, 0xe194df, + 0xf1aa86, 0xf1ad93, 0xefb5b8, 0xecc3eb, 0xffeeab, 0xfff1b5, 0xfff6d3, 0xffffff, +) + +# Per-row pixels clipped by the round display's bezel, top half; mirrored for the +# bottom. From libpebble.js:roundify. Chalk only (180px wide). +ROUNDNESS = (76, 71, 66, 63, 60, 57, 55, 52, 50, 48, 46, 45, 43, 41, 40, 38, 37, + 36, 34, 33, 32, 31, 29, 28, 27, 26, 25, 24, 23, 22, 22, 21, 20, 19, + 18, 18, 17, 16, 15, 15, 14, 13, 13, 12, 12, 11, 10, 10, 9, 9, 8, 8, 7, + 7, 7, 6, 6, 5, 5, 5, 4, 4, 4, 3, 3, 3, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + +class NoEmulator(Exception): + """No usable emulator. Typed so the model reports it instead of retrying.""" + + +# --------------------------------------------------------------------------- +# pure framing helpers (unit-tested in test_emulator_framing.py) +# --------------------------------------------------------------------------- + +def auth_frame(token): + tok = token.encode() if isinstance(token, str) else token + return bytes([OP_AUTH, len(tok)]) + tok + + +def to_watch(endpoint, payload): + """0x01 -- message *to* the watch.""" + return bytes([OP_TO_WATCH]) + struct.pack('>HH', len(payload), endpoint) + payload + + +def parse_inbound(frame): + """(opcode, endpoint, payload). endpoint is None for non-0x00 opcodes.""" + if not isinstance(frame, bytes) or not frame: + return None, None, b'' + op = frame[0] + if op == OP_FROM_WATCH: + if len(frame) < 5: + return op, None, b'' + size, endpoint = struct.unpack('>HH', frame[1:5]) + return op, endpoint, frame[5:5 + size] + return op, None, frame[1:] + + +def decode_app_log(payload): + """APP_LOGS payload: 16b uuid, >IBBH (ts, level, msglen, line), 16b filename, msg. + + Layout taken from libpebble.js:handle_app_log. + """ + if len(payload) < 40: + return payload.decode('utf-8', 'replace').strip() + timestamp, level, msg_len, line = struct.unpack('>IBBH', payload[16:24]) + filename = payload[24:40].split(b'\x00')[0].decode('utf-8', 'replace') + message = payload[40:40 + msg_len].decode('utf-8', 'replace') + return '[%s] %s:%d %s' % (LOG_LEVELS.get(level, level), filename, line, message) + + +def screenshot_header(data): + """(version, width, height, expected_pixel_bytes, remaining_data).""" + code, version, width, height = struct.unpack('>BIII', data[:13]) + if code != 0: + raise NoEmulator('watch returned screenshot error code %d' % code) + if version not in (1, 2): + raise NoEmulator('unknown screenshot format version %d' % version) + expected = width * height // 8 if version == 1 else width * height + return version, width, height, expected, data[13:] + + +def screenshot_png(version, width, height, pixels): + rows = [] + if version == 1: + stride = width // 8 + for y in range(height): + row = bytearray() + for x in range(width): + bit = (pixels[y * stride + x // 8] >> (x % 8)) & 1 + row += bytes([bit * 255]) * 3 + rows.append(bytes(row)) + else: + for y in range(height): + row = bytearray() + for x in range(width): + c = COLOUR_MAP[pixels[y * width + x] & 63] + row += bytes([(c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF]) + rows.append(bytes(row)) + if width == 180: + rows = _roundify(rows) + return encode_png(width, height, rows) + + +def _roundify(rows): + """Black out the corners the round (chalk) display clips, so the model doesn't + try to fix layout in pixels the user cannot see.""" + skips = list(ROUNDNESS) + list(reversed(ROUNDNESS)) + out = [] + for y, row in enumerate(rows): + skip = skips[y] if y < len(skips) else 0 + if skip: + row = bytearray(row) + row[:skip * 3] = b'\x00' * (skip * 3) + row[-skip * 3:] = b'\x00' * (skip * 3) + row = bytes(row) + out.append(row) + return out + + +def encode_png(width, height, rows): + raw = b''.join(b'\x00' + r for r in rows) + + def chunk(tag, body): + return (struct.pack('>I', len(body)) + tag + body + + struct.pack('>I', zlib.crc32(tag + body) & 0xFFFFFFFF)) + + return (b'\x89PNG\r\n\x1a\n' + + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(raw, 9)) + + chunk(b'IEND', b'')) + + +def ws_url(base_url, uuid): + url = base_url.rstrip('/').replace('https://', 'wss://').replace('http://', 'ws://') + return '%s/qemu/%s/ws/phone' % (url, uuid) + + +# --------------------------------------------------------------------------- +# connection +# --------------------------------------------------------------------------- + +class Emulator(object): + def __init__(self, url, token): + self.url = url + self.token = token + self.ws = None + # Set once a send/recv fails. get_emulator evicts dead connections rather than + # returning one that will fail forever. + self.dead = False + self.lock = threading.Lock() + + def connect(self, boot_timeout=BOOT_TIMEOUT): + deadline = time.time() + boot_timeout + attempt = 0 + while True: + attempt += 1 + try: + self.ws = self._handshake() + self._send(to_watch(ENDPOINT_APP_LOGS, b'\x01')) # enable app logs + return self + except (websocket.WebSocketException, OSError) as e: + if time.time() >= deadline: + raise NoEmulator('emulator never came up after %d attempts: %s' % (attempt, e)) + logger.info('emulator not ready (%s), retrying', type(e).__name__) + time.sleep(RETRY_DELAY) + + def _handshake(self): + # Well inside BOOT_TIMEOUT, so the retry loop above can actually retry. + ws = websocket.create_connection(self.url, timeout=CONNECT_TIMEOUT) + try: + ws.send_binary(auth_frame(self.token)) + deadline = time.time() + CONNECT_TIMEOUT + while time.time() < deadline: + op, _, rest = parse_inbound(ws.recv()) + if op == OP_AUTH: + if rest[:1] != b'\x00': + raise NoEmulator('emulator rejected the auth token') + elif op == OP_CONNECTION: + if rest[:1] == b'\xff': + return ws + if rest[:1] == b'\x00': + raise websocket.WebSocketException('proxy closed connection remotely') + raise websocket.WebSocketException('timed out waiting for watch connect') + except BaseException: + # Leaving the socket open would register another authed client in pypkjs's + # broadcast list for every failed attempt. + try: + ws.close() + except Exception: + pass + raise + + def _lost(self, e): + self.dead = True + return NoEmulator('emulator connection lost: %s' % e) + + def _send(self, frame): + try: + self.ws.send_binary(frame) + except (websocket.WebSocketException, OSError) as e: + raise self._lost(e) + + def _recv(self, timeout): + self.ws.settimeout(max(timeout, 0.1)) + try: + return parse_inbound(self.ws.recv()) + except websocket.WebSocketProtocolException as e: + # A socket timeout can fire mid-frame, leaving websocket-client's frame + # buffer desynced so every later recv misparses. Fatal, not ignorable. + raise self._lost('frame desync: %s' % e) + except websocket.WebSocketTimeoutException: + return None, None, b'' + except (websocket.WebSocketException, OSError) as e: + raise self._lost(e) + + def screenshot(self, timeout=60): + with self.lock: + self._send(to_watch(ENDPOINT_SCREENSHOT, b'\x00')) + buf, expected, meta = b'', None, None + deadline = time.time() + timeout + while time.time() < deadline: + op, endpoint, data = self._recv(deadline - time.time()) + if op != OP_FROM_WATCH or endpoint != ENDPOINT_SCREENSHOT: + continue + if meta is None: + version, width, height, expected, data = screenshot_header(data) + meta = (version, width, height) + buf += data + if len(buf) >= expected: + return screenshot_png(meta[0], meta[1], meta[2], buf[:expected]) + raise NoEmulator('timed out collecting screenshot (%d/%s bytes)' % (len(buf), expected)) + + def press_button(self, button, hold_ms=120): + """Hold a watch button down and let go. + + The emulator takes the set of buttons currently held, so this is + "set the bit, wait, send zero" -- exactly what the IDE's own buttons do + on mousedown/mouseup. + """ + bit = BUTTONS[button] + with self.lock: + self._send(bytes([OP_QEMU, QEMU_BUTTON, bit])) + time.sleep(max(0.02, min(hold_ms, 5000) / 1000.0)) + self._send(bytes([OP_QEMU, QEMU_BUTTON, 0])) + + def tap(self, axis='y', direction=1): + """Shake the watch: one accelerometer tap along an axis.""" + with self.lock: + self._send(bytes([OP_QEMU, QEMU_TAP, TAP_AXES[axis], + 1 if direction >= 0 else 0xFF])) + + def enable_app_logs(self): + """Ask the watch to start shipping APP_LOG output. + + Nothing arrives until this is sent -- the browser does it after every + install (see libpebble.js enable_app_logs), and without it `logs()` + returns nothing at all no matter what the app prints. That left the model + debugging an AppMessage round trip blind, and it shipped an offline demo + rather than the live data it had been asked for. + """ + with self.lock: + self._send(to_watch(ENDPOINT_APP_LOGS, b'\x01')) + + def install(self, pbw, timeout=180): + with self.lock: + self._send(bytes([OP_INSTALL]) + pbw) + deadline = time.time() + timeout + while time.time() < deadline: + op, _, data = self._recv(deadline - time.time()) + if op == OP_INSTALL_STATUS and len(data) >= 4: + return struct.unpack('>I', data[:4])[0] + raise NoEmulator('timed out waiting for install to finish') + + def logs(self, seconds): + with self.lock: + out = [] + deadline = time.time() + seconds + while time.time() < deadline: + op, endpoint, data = self._recv(deadline - time.time()) + if op == OP_PHONE_LOG: + out.append(data.decode('utf-8', 'replace').strip()) + elif op == OP_FROM_WATCH and endpoint in (ENDPOINT_APP_LOGS, ENDPOINT_LOGS): + out.append(decode_app_log(data)) + return out + + def close(self): + try: + self.ws.close() + except Exception: + pass + + +# One connection per emulator, process wide, because the same emulator survives across +# turns (gotcha 2). +# ponytail: unbounded dicts, one entry per emulator the box ever talked to, and the lock +# is held across the attach. Add an LRU sweep and a per-uuid lock if this service ever +# runs more than a handful of concurrent sessions. +_CONNS = {} +_FAILURES = {} +_CONNS_LOCK = threading.Lock() + + +def get_emulator(spec, cp_base_url): + """spec is the `emulator` field of /turn: {uuid, token, ws_url?}. + + ws_url is built by Django from QEMU_PUBLIC_URL, never by the browser -- see + ide/api/agent.py:_emulator_spec. There is deliberately no other way to point this + at a host of the caller's choosing. + """ + if not spec or not spec.get('uuid') or not spec.get('token'): + raise NoEmulator('no emulator is running -- open the emulator in CloudPebble first') + uuid = spec['uuid'] + with _CONNS_LOCK: + conn = _CONNS.get(uuid) + if conn is not None and conn.dead: + # pypkjs broadcasts to every authed socket and the controller opens a fresh + # connection per client, so a second attach is supported. Allow exactly one. + _CONNS.pop(uuid, None) + conn.close() + conn = None + if conn is not None: + return conn + + failed_until, failure = _FAILURES.get(uuid, (0, None)) + if failure is not None and time.time() < failed_until: + # The model will try install, then screenshot, then logs. Without this each + # one spends BOOT_TIMEOUT re-dialling an emulator that already said no. + raise failure + + conn = Emulator(spec.get('ws_url') or ws_url(cp_base_url, uuid), spec['token']) + try: + conn.connect() + except NoEmulator as e: + _FAILURES[uuid] = (time.time() + FAILURE_TTL, e) + raise + _FAILURES.pop(uuid, None) + _CONNS[uuid] = conn + return conn diff --git a/cloudpebble-agent/project_state.py b/cloudpebble-agent/project_state.py new file mode 100644 index 0000000..6c7bf9f --- /dev/null +++ b/cloudpebble-agent/project_state.py @@ -0,0 +1,151 @@ +"""Render the project and emulator the agent is actually working on. + +The agent used to start every turn blind: it knew the project id and nothing +else, so it guessed at the target platform (and so at the screen size it was +laying out for), could not see whether the project was already flagged as a +watchface, and did not know whether it was a C or an Alloy project until it +happened to list the files. Guessing wrong is expensive here -- a layout built +for 144x168 is cropped on emery, and a watchface built as a watch app looks +perfect in the screenshot and is still wrong. + +So the state goes into the turn: settings, file tree, and which watch is +actually running in the user's browser right now. + +This is appended to the user's message rather than prepended to the system +prompt on purpose. Providers that cache by prefix (DeepSeek, Moonshot) only hit +when the prefix is identical from the 0th token, and this block changes every +turn -- at the front it would destroy the cache, at the end it costs nothing. +""" + +# Screen geometry per platform. The agent gets this from the skill too, but the +# skill is a document it may not have read yet when it starts laying out. +SCREENS = { + 'aplite': ('144x168', 'rectangular', 'black and white'), + 'basalt': ('144x168', 'rectangular', '64 colours'), + 'chalk': ('180x180', 'round', '64 colours'), + 'diorite': ('144x168', 'rectangular', 'black and white'), + 'emery': ('200x228', 'rectangular', '64 colours'), + 'gabbro': ('260x260', 'round', '64 colours'), + 'flint': ('144x168', 'rectangular', '64 colours'), +} + + +def screen_line(platform): + spec = SCREENS.get(platform) + if not spec: + return platform + return '%s, %s %s, %s' % (platform, spec[0], spec[1], spec[2]) + + +def _yn(value): + return 'yes' if value else 'no' + + +def settings_block(info): + """The project's settings, as the agent needs to reason about them.""" + lines = [] + project_type = info.get('type') or 'native' + language = 'Alloy (JavaScript on the watch)' if project_type == 'alloy' else ( + 'C' if project_type in ('native', 'package') else project_type) + lines.append('name: %s' % info.get('name', '')) + lines.append('language: %s (project_type=%s, fixed at creation -- you cannot change it)' + % (language, project_type)) + lines.append('is_watchface: %s%s' % ( + _yn(info.get('app_is_watchface')), + '' if info.get('app_is_watchface') + else ' <- builds as a watch APP, not a face. set_app_settings(app_is_watchface=true) if the user wants a face.')) + + platforms = info.get('app_platforms') or '' + enabled = [p for p in platforms.split(',') if p] or list(info.get('supported_platforms') or []) + lines.append('target platforms: %s' % (', '.join(enabled) if enabled else '(all supported)')) + for platform in enabled: + lines.append(' %s' % screen_line(platform)) + lines.append('platforms this project type supports: %s' + % ', '.join(info.get('supported_platforms') or [])) + + lines.append('uuid: %s' % (info.get('app_uuid') or '')) + lines.append('version: %s' % (info.get('app_version_label') or '')) + lines.append('long name: %s' % (info.get('app_long_name') or '')) + lines.append('short name: %s' % (info.get('app_short_name') or '')) + lines.append('company: %s' % (info.get('app_company_name') or '')) + lines.append('capabilities: %s' % (info.get('app_capabilities') or '(none)')) + lines.append('message keys: %s' % (info.get('app_keys') or '(none)')) + dependencies = info.get('app_dependencies') or {} + lines.append('dependencies: %s' % (', '.join('%s@%s' % kv for kv in sorted(dependencies.items())) + or '(none)')) + lines.append('multi-JS: %s' % _yn(info.get('app_modern_multi_js'))) + lines.append('hidden from launcher: %s' % _yn(info.get('app_is_hidden'))) + return lines + + +# Where a file is allowed to live, per project type. CloudPebble derives a file's +# target from its path and rejects anything else -- "Unacceptable file extension +# for app file in [src/index.js]" means the path was wrong, not that the file +# type is unsupported. An agent that reads that as "I cannot write pkjs here" +# silently ships without its JS, which is what happened before this was spelled +# out. +WRITABLE_PATHS = { + 'native': ['src/c/*.c and src/c/*.h — watch-side C', + 'src/pkjs/*.js and *.json — phone-side JS (weather, web requests)', + 'worker_src/c/*.c — background worker', + 'images and fonts are resources, not files: write_resource'], + 'alloy': ['src/embeddedjs/main.js and manifest.json — watch-side JS', + 'src/embeddedjs/*.png, *.pdc, *.ttf — assets, via write_binary_file', + 'src/pkjs/*.js and *.json — phone-side JS', + 'src/c/mdbl.c — boot stub, already there, do not touch'], + 'package': ['src/c/*.c and *.h', 'include/*.h', 'src/js/*.js'], + 'rocky': ['src/rocky/*.js', 'src/pkjs/*.js', 'src/common/*.js'], +} + + +def paths_block(project_type): + return WRITABLE_PATHS.get(project_type or 'native', WRITABLE_PATHS['native']) + + +def files_block(info): + lines = [] + for f in info.get('source_files') or []: + lines.append(' %s (target=%s)' % (f.get('file_path'), f.get('target'))) + if not lines: + lines.append(' (no source files yet)') + resources = info.get('resources') or [] + if resources: + lines.append('resources:') + for r in resources: + ids = ', '.join(r.get('identifiers') or []) + lines.append(' %s (%s%s)' % (r.get('file_name'), r.get('kind'), + ', ids: %s' % ids if ids else '')) + return lines + + +def emulator_block(emulator): + """Which watch is on screen in front of the user, if any.""" + platform = (emulator or {}).get('platform') + if not emulator: + return ['no emulator is open: install() and screenshot() will fail until the ' + 'user opens one. build() still works.'] + if not platform: + return ['an emulator is open, but its platform was not reported. ' + 'screenshot() shows what it actually is.'] + return ['running: %s' % screen_line(platform), + 'screenshot() and install() act on THIS watch. If it is not in the target ' + 'platform list above, either lay out for it too or say so.'] + + +def render(info, emulator=None): + """The whole block. Returns '' when there is nothing trustworthy to say.""" + if not info: + return '' + out = ['', + 'This is the live state of the project you are working on, read just now.', + '', + '## Settings (change with set_app_settings, not by writing files)'] + out += settings_block(info) + out += ['', '## Files (read/write by these paths)'] + out += files_block(info) + out += ['', 'New files must go in one of these, or the write is refused:'] + out += [' %s' % line for line in paths_block(info.get('type'))] + out += ['', '## Emulator'] + out += emulator_block(emulator) + out += [''] + return '\n'.join(out) diff --git a/cloudpebble-agent/requirements.txt b/cloudpebble-agent/requirements.txt new file mode 100644 index 0000000..f9548cc --- /dev/null +++ b/cloudpebble-agent/requirements.txt @@ -0,0 +1,5 @@ +claude-agent-sdk==0.2.128 +fastapi==0.121.2 +uvicorn==0.41.0 +requests==2.32.5 +websocket-client==1.9.0 diff --git a/cloudpebble-agent/service.py b/cloudpebble-agent/service.py new file mode 100644 index 0000000..0b249e7 --- /dev/null +++ b/cloudpebble-agent/service.py @@ -0,0 +1,90 @@ +"""Agent service: the Claude Agent SDK loop behind three endpoints. + +Not public. CloudPebble calls it with a shared secret in the Authorization header, +exactly like it calls the qemu controller. + + POST /turn {session_id, project_id, cp_token, cp_base_url, emulator, message, + sdk_session_id?} -> SSE stream of agent events + POST /cancel {session_id} + GET /health +""" + +import asyncio +import hmac +import json +import logging +import os + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import StreamingResponse + +from agent_loop import run_turn + +logging.basicConfig(level=os.environ.get('LOG_LEVEL', 'INFO'), + format='%(asctime)s %(levelname)s %(name)s %(message)s') +logger = logging.getLogger(__name__) + +AUTH = os.environ.get('AGENT_AUTH_HEADER', '') + +app = FastAPI() + +# session_id -> asyncio.Event, set by /cancel, checked by the turn loop. +_cancels = {} + + +def _check_auth(request): + if not AUTH: + raise HTTPException(500, 'AGENT_AUTH_HEADER is not configured') + if not hmac.compare_digest(request.headers.get('authorization', ''), AUTH): + raise HTTPException(403, 'forbidden') + + +@app.get('/health') +async def health(): + return {'ok': True} + + +@app.post('/cancel') +async def cancel(request: Request): + _check_auth(request) + body = await request.json() + event = _cancels.get(str(body.get('session_id'))) + if event is not None: + event.set() + return {'ok': True, 'cancelled': event is not None} + + +@app.post('/turn') +async def turn(request: Request): + _check_auth(request) + body = await request.json() + for field in ('session_id', 'project_id', 'cp_token', 'cp_base_url', 'message'): + if not body.get(field): + raise HTTPException(400, 'missing %s' % field) + + session_id = str(body['session_id']) + event = asyncio.Event() + _cancels[session_id] = event + + async def stream(): + try: + async for envelope in run_turn( + project_id=body['project_id'], + cp_token=body['cp_token'], + cp_base_url=body['cp_base_url'], + emulator=body.get('emulator'), + message=body['message'], + sdk_session_id=body.get('sdk_session_id'), + # Keeps the CLI's config/transcript dir stable across turns, which + # is what lets prefix-caching providers hit their cache. + session_key=session_id, + provider=body.get('provider'), + cancel=event, + ): + yield 'data: %s\n\n' % json.dumps(envelope) + finally: + _cancels.pop(session_id, None) + + return StreamingResponse(stream(), media_type='text/event-stream', + headers={'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no'}) diff --git a/cloudpebble-agent/session_store.py b/cloudpebble-agent/session_store.py new file mode 100644 index 0000000..7f94f9c --- /dev/null +++ b/cloudpebble-agent/session_store.py @@ -0,0 +1,56 @@ +"""SessionStore backed by the CloudPebble transcript routes. + +The agent box keeps nothing between turns: the transcript lives in CloudPebble's +AgentTranscript table and is hydrated at the start of each turn. + +Wire contract with ide/api/agent.py: + POST /ide/agent/transcript/ {project_key, subpath, entries: [...]} + GET /ide/agent/transcript/?subpath=... -> {entries: [...]} + -> 404 if never written +Both authenticate with the scoped agent bearer token. + +Only append() and load() are implemented; the rest of the SessionStore protocol is +optional and we never list, fork or delete sessions from here. +""" + +import asyncio +import logging + +import requests + +logger = logging.getLogger(__name__) + +TIMEOUT = 30 + + +class CloudPebbleSessionStore(object): + def __init__(self, base_url, token): + self.base = base_url.rstrip('/') + self.session = requests.Session() + self.session.headers['Authorization'] = 'Bearer %s' % token + + def _url(self, key): + return '%s/ide/agent/transcript/%s' % (self.base, key['session_id']) + + async def append(self, key, entries): + def _post(): + r = self.session.post(self._url(key), json={ + 'project_key': key['project_key'], + 'subpath': key.get('subpath') or '', + 'entries': entries, + }, timeout=TIMEOUT) + r.raise_for_status() + + await asyncio.to_thread(_post) + + async def load(self, key): + def _get(): + r = self.session.get(self._url(key), + params={'subpath': key.get('subpath') or ''}, + timeout=TIMEOUT) + if r.status_code == 404: + return None + r.raise_for_status() + return r.json().get('entries') or None + + return await asyncio.to_thread(_get) diff --git a/cloudpebble-agent/skills/pebble-watchface/SKILL.md b/cloudpebble-agent/skills/pebble-watchface/SKILL.md new file mode 100644 index 0000000..2913005 --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/SKILL.md @@ -0,0 +1,758 @@ +--- +name: pebble-watchface +description: Generate complete Pebble smartwatch watchfaces AND watchapps (games, tools, web-API apps), in C or Alloy (JavaScript), build PBW artifacts, and test in QEMU emulator. Use when creating watchfaces, Pebble apps, animated displays, clock faces, watch games. Produces ready-to-install PBW files and runs them in emulator. +--- + +# Pebble Watchface & Watchapp Generator + +Generate complete, buildable Pebble watchfaces and watchapps with full PBW artifact output and QEMU testing. + +**Default target platform: Emery (Pebble Time 2, 200x228 color rectangular display).** + +> **Running inside CloudPebble.** Read [CloudPebble Delta](#cloudpebble-delta) first — it +> overrides every shell command, file path, and project-scaffolding instruction below. + +## Step 0: Watchface or Watchapp? + +The language is not yours to choose — see [CloudPebble Delta](#cloudpebble-delta). The +watchface/watchapp flag is, and it is a project setting rather than a file: + +| | Watchface | Watchapp | +|---|---|---| +| CloudPebble setting | `set_app_settings(app_is_watchface=true)` | `set_app_settings(app_is_watchface=false)` | +| Purpose | Passive time display | Interactive: games, tools, viewers | +| Buttons | **None** (Up/Down = timeline, Select = launcher). Input = accelerometer tap only | Full click API: single/repeating/long/multi/raw | +| Exit | System-controlled | BACK pops window stack; app exits when empty | +| Update driver | Tick timer (MINUTE_UNIT) | Clicks + AppTimer loops (games ~33ms) | + +If the request involves buttons, menus, game input, or multiple screens → watchapp. Read [reference/watchapp-guide.md](reference/watchapp-guide.md) before implementing a watchapp. + +### Which language is this project in? + +Not a choice — the open project already is one or the other, and you cannot convert it. +Read it off `list_files()`: + +| File tree shows | Language | Write | +|---|---|---| +| `src/embeddedjs/main.js` (target=embeddedjs) | Alloy — JS on the watch (Moddable XS) | `src/embeddedjs/main.js`, `src/embeddedjs/manifest.json` | +| `src/c/main.c` and no embeddedjs files | C | `src/c/*.c` | + +Alloy projects also carry `src/c/mdbl.c` — VM boot boilerplate. **Never edit or delete it.** + +Read [reference/alloy-guide.md](reference/alloy-guide.md) fully before writing an Alloy +project. If the user asks for something the project's language cannot do (Alloy runs on +emery, gabbro and flint only; C has no watch-side `fetch()`), say so and offer to have them +create a new project of the other type — you cannot change `project_type`. + +## CRITICAL: End-to-End Delivery + +This skill MUST produce a final `.pbw` file, test it, AND visually verify it looks correct. + +Every watchface request follows this complete flow: + +1. **Research** → [SUBAGENT] Gather requirements, study samples and tutorials +2. **Design** → [SUBAGENT] Plan architecture and visuals +3. **Implement** → `write_file()` all project sources +4. **Build** → `build()` to compile on the CloudPebble build farm +5. **Test** → `install()`, then `screenshot()` and look at the image +6. **Iterate** → Fix issues until the screenshot looks good +7. **Deliver** → Describe the verified screenshot in chat + +**Never stop until:** +- `build()` reports success +- `screenshot()` returned an image of the running watchface +- Visual verification confirms it looks correct +- A FINAL `screenshot()` of the finished result is the last thing you did before + writing your reply. Screenshot after every install, and again after every + visual change: the user is watching a panel that renders each one, and they are + cheap. + +## CRITICAL: Battery Efficiency + +**ALWAYS use `MINUTE_UNIT` for `tick_timer_service_subscribe()`.** NEVER use `SECOND_UNIT` unless the user explicitly requests a seconds display. `SECOND_UNIT` causes the watchface to redraw every second, which drastically reduces battery life. Design all watchfaces to update on minute boundaries. + +For animated watchfaces that use `app_timer_register()`, only run animations briefly (e.g., on a tap event or for a few seconds after minute change), then stop the timer. Continuous animation is acceptable only when the user explicitly requests it. + +--- + +## CloudPebble Delta + +This is a fork of `coredevices/pebble-watchface-agent-skill` running inside CloudPebble. +Where this section contradicts the rest of the file, this section wins. + +**There is no bash, no filesystem, no `pebble` CLI.** Every action is a tool call. No +`mkdir`, no `cd`, no `ls`, no `python3 scripts/...`. Only `SKILL.md`, `reference/` and +`templates/` are shipped — the upstream `scripts/`, `samples/` and `tutorials/` directories +are not here, so do not try to read them. Where the text below points at a sample or +tutorial, use the templates and reference docs instead. + +**You work on an existing project, not a new directory.** The project already exists with a +UUID, a name, and a source tree. Start with `list_files()`, not with scaffolding. + +**You are already told the state.** Every message you get ends with a `` +block: settings, target platforms with their screen sizes, the file tree, and which watch +the emulator is running right now. Lay out for what it says, not for a default. `list_files()` +returns the same block, refreshed — call it after changing settings or when in doubt. + +**A watchface MUST be flagged as one, or it is not a watchface.** New CloudPebble projects +default to `is_watchface = false`, which builds a watch *app*: it lands in the app menu and +never appears as a face, no matter how the C code looks. The screenshot looks perfect and +the result is still wrong. So whenever the user asks for a watchface, call: + + set_app_settings(app_is_watchface=true) + +Do this BEFORE the first `build()`. It is cheap and idempotent — if it is already set, +setting it again costs nothing. + +**And the same in reverse.** A watch app MUST have `app_is_watchface=false`, or it installs +over the user's clock instead of appearing in the app menu they open it from. When the user +corrects you about what they are building — "this isn't a watchface, I want an app" — +rewriting the code is only half the fix: call `set_app_settings` in the same breath. +Observed: an app was rewritten correctly and left flagged as a face, so it still replaced +the watchface. + +`set_app_settings` also carries the rest of the project's settings: `app_platforms`, +`app_long_name`, `app_short_name`, `app_company_name`, `app_version_label`, +`app_capabilities` (`location`, `health`, `configurable`), `app_keys`, `menu_icon`, +`app_is_hidden`, `app_is_shown_on_communication`, `app_modern_multi_js`, `name`. Only the +fields you pass change. Use it instead of hunting for a `package.json` — there isn't one. +It is scoped to this project and cannot touch anything else in the account. + +**Files are addressed by `project_path`, not disk path.** `write_file("src/c/main.c", ...)` +— the same paths the file tree shows. Creating a path that does not exist creates the file. + +**No `wscript`, no `package.json`.** CloudPebble generates both from project settings. They +are not in the source tree and you cannot write them. That means: + +- **Target platforms come from the project's `app_platforms` setting**, not from + `targetPlatforms` in a package.json. Emery may or may not be enabled. Read the platform + list from the `` block and lay out for *that*, not for emery by default. + When in doubt, use `layer_get_bounds()` and the layout works everywhere. +- UUID, display name, SDK version, `enableMultiJS`, `capabilities` and `messageKeys` are + project settings, and `set_app_settings` writes all of them: `app_uuid`, `app_keys`, + `app_capabilities`, `app_platforms`, `app_dependencies`, `menu_icon` and the rest. You + do not need to ask the user for any of it. + +**The project's language is fixed.** A CloudPebble project is C or Alloy from the moment it +is created, and `project_type` is not something `set_app_settings` can change. Do not +choose between them: read `list_files()` and write in whichever the project already is — +`src/embeddedjs/main.js` present means Alloy, otherwise C. If the request needs the other +one, say so and offer to have the user create a new project. + +**Dependencies are a setting.** There is no `pebble package install`, but npm packages +are yours to set: `set_app_settings(app_dependencies='{"@moddable/pebbleproxy": "^0.1.3"}')`. +It replaces the whole list, so include any that are already there. + +**Images and fonts are resources.** `write_resource(file_name, kind, content_base64, +resource_ids)` adds one and gives it the id C code draws with (`RESOURCE_ID_`); kinds +are `png`, `png-trans`, `bitmap`, `pbi`, `font`, `raw`. Replacing keeps the existing ids. +`delete_resource` removes one. Assets travel base64 through the tool, so keep them small -- +drawing in code is usually cheaper and always sharper than a shipped bitmap. + +**No subagents.** Do the research and design phases inline. Phases 1 and 2 are still worth +doing, just do them yourself. + +**The emulator belongs to the user's browser tab.** You cannot start, stop or reboot it. +If `install()` or `screenshot()` returns a no-emulator error, or says the emulator +rejected the install, that is a fact about their browser and NOT a fault in your code — +the panel starts one for them automatically and tells you when it is ready. Keep going +with `build()` meanwhile, and never delete working features to bisect an emulator problem. + +**Read the reference docs with `read_reference`, not `read_file`.** `read_file` only sees +project source files. `read_reference('reference/alloy-guide.md')` serves this skill's own +guides and templates; call it with no name to list them. Read the guide before guessing at +an API in a runtime you do not know. + +**Phase 6 (assets) and Phase 8 (publish) do not apply.** Deliver at Phase 7: describe the +verified screenshot, and say the build is installed in their emulator. + +--- + +## Platform Reference + +| Platform | Model | Resolution | Shape | Colors | +|----------|-------|------------|-------|--------| +| **emery** | **Pebble Time 2** | **200x228** | **Rect** | **64-color** | +| gabbro | Pebble Round 2 | 260x260 | Round | 64-color | +| basalt | Pebble Time | 144x168 | Rect | 64-color | +| chalk | Pebble Time Round | 180x180 | Round | 64-color | +| aplite | Pebble Classic | 144x168 | Rect | B&W | +| diorite | Pebble 2 | 144x168 | Rect | B&W | +| flint | Pebble 2 Duo | 144x168 | Rect | 64-color | + +**Emery is the default and exclusive target.** If the user wants gabbro (round) support, that should be a second pass after the emery version is finalized. + +--- + +## Phase 1: Research [USE SUBAGENT — complex projects only] + +**Skip subagents for simple projects.** If the request is covered by a template plus one reference doc (basic digital/analog face, single-screen app), read the template and relevant reference file directly and go straight to implementation. Spawn subagents only for complex projects: multi-screen apps, games with novel mechanics, heavy custom graphics, or unfamiliar API combinations. + +**For complex projects, spawn a research subagent** using Agent tool with `subagent_type: "Explore"` to: + +### Gather Requirements +Ask the user (use AskUserQuestion if unclear): +- **Type**: Digital, analog, animated, or artistic? +- **Elements**: Time, date, battery, weather, custom graphics? +- **Animation**: Static, subtle, or complex animations? +- **Weather/Web data**: Does it need weather or other internet data? + +### Study Existing Code +The subagent should read and analyze: +- `samples/aqua-pbw/src/c/main.c` — animated watchface patterns +- `tutorials/c-watchface-tutorial/part1/` — basic time + date +- `tutorials/c-watchface-tutorial/part4/` — weather via AppMessage + pkjs + +Key patterns to extract: +- Data structures for animated elements +- Animation loop structure +- Drawing functions +- Memory management patterns +- Battery-aware throttling +- Weather/AppMessage communication (if needed) + +Also have subagent read relevant reference docs: +- `reference/pebble-api-reference.md` +- `reference/animation-patterns.md` +- `reference/drawing-guide.md` +- `reference/watchapp-guide.md` — if building a watchapp (buttons, menus, window stack, game loop) +- `reference/alloy-guide.md` — if building in Alloy (JS) + +--- + +## Phase 2: Design [USE SUBAGENT — complex projects only] + +For simple projects, do the layout math below inline (it is mandatory either way). **For complex projects, spawn a planning subagent** using Agent tool with `subagent_type: "Plan"` to: + +### Create Design Specification +- Screen layout for **emery (200x228)** rectangular display +- Element positions and sizes +- Animation behavior and timing (intervals, speeds) +- Color scheme (64-color palette) +- Data structures needed +- Layer hierarchy +- Whether weather/web data is needed (requires pkjs) + +### CRITICAL: Layout Planning to Prevent Cropping +**You MUST calculate exact pixel positions to ensure nothing is cropped.** + +For emery (200x228): +``` +Available space: X: 0-199, Y: 0-227 +Safe margins: 2-5 pixels from edges +``` + +For each visual element, calculate: +1. **Y position**: Where does it start vertically? +2. **Element height**: How tall is it? +3. **Bottom edge**: Y_position + height must be < 228 (with margin) + +Example layout calculation for emery: +``` +SCREEN_WIDTH = 200, SCREEN_HEIGHT = 228 +Time text: Y=60, height=50 → bottom at 110 ✓ +Date text: Y=115, height=26 → bottom at 141 ✓ +Weather: Y=145, height=24 → bottom at 169 ✓ +Battery bar: Y=0, height=3 → bottom at 3 ✓ +``` + +**FAIL CONDITIONS to check in design:** +- Element bottom edge >= SCREEN_HEIGHT (228 for emery) +- Element right edge >= SCREEN_WIDTH (200 for emery) +- GPath points with negative offsets that extend beyond anchor point +- Elements positioned relative to SCREEN_HEIGHT without accounting for element size + +### GPath Positioning Guide +GPaths use **relative coordinates from an anchor point**. Calculate carefully: + +```c +// GPath points are RELATIVE to where you move_to +static GPoint castle_points[] = { + {-35, 0}, // 35px LEFT of anchor, AT anchor Y + {-35, -40}, // 35px left, 40px ABOVE anchor + {35, 0}, // 35px RIGHT of anchor +}; + +// Anchor positioning calculation: +// If castle_points go from Y=0 to Y=-40 (40px tall, extending UP) +// And you want bottom of castle at Y=223 (5px margin from 228) +// Then anchor Y = 223 (the base of the castle) +gpath_move_to(castle_path, GPoint(SCREEN_WIDTH/2, 223)); +``` + +### Architecture Planning +- What structs are needed? +- How many animated elements? +- Update interval (MINUTE_UNIT for tick, 50ms for brief animations) +- Memory pre-allocation strategy +- Does it need pkjs for weather/web data? + +--- + +## Phase 3: Implementation + +**Do this directly** (not a subagent) — write all files: + +### Write ALL Required Files + +**1. Project settings** — not files. UUID, display name, SDK version, target platforms, +`enableMultiJS`, `capabilities` and `messageKeys` all live in CloudPebble's project +settings, and there is no `package.json` or `wscript` in the source tree to write. If the +watchface needs `messageKeys` or the `location` capability, say so in chat and ask the +user to set it in Settings. + +**2. Platform bounds** — the `` block lists the enabled platforms and their +screen sizes. Lay out for those. `layer_get_bounds()` works everywhere. + +**3. src/c/main.c** (REQUIRED) +Write complete watchface code following the design from Phase 2. + +Use templates as starting points: +- [templates/animated-watchface.c](templates/animated-watchface.c) — animated watchfaces +- [templates/static-watchface.c](templates/static-watchface.c) — static/analog watchfaces +- [templates/weather-watchface.c](templates/weather-watchface.c) — watchfaces with weather data + +**4. src/pkjs/index.js** (REQUIRED if weather/web data needed) +Use [templates/pkjs-weather.js](templates/pkjs-weather.js) as starting point. + +The pkjs file runs on the phone and handles: +- GPS location via `navigator.geolocation.getCurrentPosition()` +- HTTP requests via `XMLHttpRequest` to web APIs +- Sending data to watch via `Pebble.sendAppMessage()` +- Receiving requests from watch via `appmessage` event + +**Open-Meteo API** (free, no API key): +``` +https://api.open-meteo.com/v1/forecast?latitude=LAT&longitude=LON¤t=temperature_2m,weather_code +``` + +### Code Requirements +- `#include ` +- Implement `main()`, `init()`, `deinit()` +- Window with load/unload handlers +- `tick_timer_service_subscribe(MINUTE_UNIT, tick_handler)` — **ALWAYS MINUTE_UNIT** +- For brief animations: `app_timer_register()` with 50ms interval +- Pre-allocate GPath in window_load +- Destroy all resources in unload handlers +- Fixed-point math only (sin_lookup/cos_lookup) +- Use `layer_get_bounds()` for screen dimensions — don't hardcode sizes +- Register AppMessage callbacks BEFORE calling `app_message_open()` + +### Watchapp Differences (C) + +For watchapps (`set_app_settings(app_is_watchface=false)`), see [reference/watchapp-guide.md](reference/watchapp-guide.md). Deltas from the watchface flow: +- Add `window_set_click_config_provider()` — buttons work +- Multi-screen: one Window per screen, push/pop on the window stack +- Games: AppTimer loop at ~33ms calling `layer_mark_dirty()`, raw click subscriptions for held buttons, cancel timer in window disappear +- MenuLayer/ScrollLayer call their own `*_set_click_config_onto_window()` +- Save state with `persist_*` in window disappear +- No MINUTE_UNIT constraint — apps redraw on input/timer, but still cancel timers when idle + +### Alloy Implementation (when the project is an Alloy project) + +See [reference/alloy-guide.md](reference/alloy-guide.md) — read it fully before writing +files. CloudPebble already created the scaffolding; you edit these: + +``` +src/embeddedjs/main.js # all watch logic — start from templates/alloy-watchface.js +src/embeddedjs/manifest.json # register every extra module and font here +src/pkjs/index.js # ONLY if networking (pebbleproxy) or Clay settings +src/c/mdbl.c # VM boot stub, already present — NEVER touch +``` + +There is no `package.json` and no `wscript` to write — CloudPebble generates both. + +Key rules: +- Platforms come from `app_platforms`; Alloy builds for emery, gabbro and flint only +- Watchface: subscribe to `minutechange` — it fires immediately on registration, so that IS + the initial draw. CloudPebble's own Alloy templates use `Pebble.addEventListener(...)` + while the guide shows `watch.addEventListener(...)`; `read_file()` the project's existing + `main.js` and keep whichever form is already there +- Watchapp buttons: `import Button from "pebble/button"` (apps only) +- Networking: the `@moddable/pebbleproxy` dependency plus a 3-line pkjs shim, then watch-side `fetch()` works. You cannot add dependencies — ask the user to add it in the Dependencies pane +- Animation: `setInterval(draw, 33)` for ~30fps; stop when done +- Extra JS module files MUST be registered in manifest.json `modules` + +--- + +## Phase 4: Build PBW + +### Run the Build +Call `build()`. It compiles on the CloudPebble build farm and returns the build status +plus the full compiler log. A failed compile is a normal result, not an error — read the +log and fix the code. + +### Handle Build Errors +If the build fails: +1. Read the compiler errors in the returned log +2. Fix the C code (syntax, types, missing includes) +3. Call `build()` again +4. Repeat until it succeeds + +--- + +## Phase 5: Test in QEMU Emulator + +**REQUIRED** — Must test AND visually verify before delivering. + +### Step 1: Install +Call `install()`. It pushes the last successful build into the emulator the user has open +in their browser. If it returns a no-emulator error, that is not a build failure — tell +the user to open the emulator, and carry on with `build()`. + +Give it a couple of seconds to load and render before screenshotting. + +### Emulator Hygiene (common failure modes) + +- **Screenshot shows a different app, an old watchface, or the watchface picker** → the + emulator is not showing what you just installed. `install()` again and re-screenshot. Do + NOT debug your code from a screenshot of something else. +- **Screenshot shows the launcher with your app highlighted but not running** → either it + was never launched, or it crashed. `install()` again, then check `logs()`. +- **Install or screenshot times out** → the emulator is wedged or gone. It belongs to the + user's browser tab: ask them to restart it from Build & Run, and keep using `build()` + meanwhile. + +### Step 2: Capture Screenshot (MANDATORY) +Call `screenshot()`. The image comes back to you directly — there is no file to read. + +### Step 3: Visual Verification (MANDATORY) + +Look at the image `screenshot()` returned. + +**CRITICAL: Perform thorough visual verification using this detailed checklist.** + +#### A. Cropping Check (FAIL if any element is cut off) +- [ ] **All visual elements fully visible** — No element should be cut off at screen edges +- [ ] **Key graphics not clipped** — Main visual elements must be 100% within 200x228 bounds +- [ ] **No overflow at bottom** — Elements near y=228 must have margin +- [ ] **No overflow at sides** — Elements near x=0 or x=200 must have margin +- [ ] **Text not truncated** — All text fits within its designated area + +#### B. Positioning Check (FAIL if layout doesn't match design) +- [ ] **Time in correct position** — Matches the designed location +- [ ] **Visual elements properly placed** — Each element appears where designed +- [ ] **Proportional spacing** — Elements have appropriate margins +- [ ] **Center alignment** — Centered elements are actually centered (x=100 center) + +#### C. Color Scheme Check (FAIL if colors don't match design) +- [ ] **Primary colors correct** — Main colors match design spec +- [ ] **Contrast sufficient** — Text and elements are readable + +#### D. Design Intent Check (FAIL if doesn't match user request) +- [ ] **Theme recognizable** — Watchface represents the requested theme +- [ ] **Key features prominent** — Main visual features are visible +- [ ] **Overall composition balanced** — Layout looks intentional + +**STOP AND FIX if ANY check fails.** Do not proceed to delivery with visual issues. + +### Step 4: Fix Issues and Re-test + +If visual verification fails: + +#### Fixing Cropping Issues +- **Bottom cropping**: Reduce Y coordinates, use `bounds.size.h - H - margin` formula +- **Side cropping**: Use `bounds.size.w / 2 - element_width / 2` for centering +- **Common mistake**: Hardcoding 144x168 values instead of using `layer_get_bounds()` + +#### Iteration Process: +1. Identify which check(s) failed +2. Apply the specific fix +3. Rebuild: `build()` +4. Reinstall: `install()` +5. New screenshot: `screenshot()` +6. Re-verify by looking at the new image +7. **Repeat until ALL checks pass** + +### Step 5: Check Logs for Errors +Call `logs(seconds)` to drain app logs from the emulator. Logs only start flowing once the +app is running, so call it after `install()`; a crash may only show up after a reinstall. + +Look for: +- APP_LOG errors +- Crashes or exceptions +- Memory warnings + +--- + +## Phase 6: Generate Assets — not applicable + +App icons, preview GIFs and the `scripts/` directory are not available here. Skip +straight to Phase 7. + +--- + +## Phase 7: Deliver + +### Report to User +After successful build AND visual verification: + +1. **A final screenshot.** Take a fresh `screenshot()` of the finished result, + after the last build and install — not one from earlier in the turn. The chat + panel shows it to the user, and it is the only proof they have that the thing + works. Ending without one is an incomplete turn. + +2. **Build**: say which build id succeeded — the user can open it in Build & Run + +3. **Visual Confirmation**: describe what that final screenshot shows + +4. **Installed**: say the build is installed in the emulator they are watching + +5. **Round support**: suggest a second pass for gabbro (260x260) if the user wants it + +--- + +## Phase 8: Publish to Pebble App Store — not applicable + +Publishing is not available here. There is no `pebble` CLI and no login. + +--- + +## Weather Watchface Architecture + +When a watchface needs weather or other web data, use the **AppMessage + PebbleKit JS** pattern: + +``` +Watch (C code) ←AppMessage→ Phone (PebbleKit JS) ←HTTP→ Web API +``` + +### Required Files +1. **src/c/main.c** — C code with AppMessage handlers +2. **src/pkjs/index.js** — JavaScript running on phone + +### Required Project Settings +`enableMultiJS`, the `location` capability and the `messageKeys` +(`TEMPERATURE`, `CONDITIONS`, `REQUEST_WEATHER`) are project settings, not files. Ask the +user to set them in Settings — you cannot. + +### C Side Pattern +```c +// In init(), register callbacks BEFORE opening: +app_message_register_inbox_received(inbox_received_callback); +app_message_open(128, 128); + +// Receive weather data: +static void inbox_received_callback(DictionaryIterator *iterator, void *context) { + Tuple *temp_tuple = dict_find(iterator, MESSAGE_KEY_TEMPERATURE); + Tuple *cond_tuple = dict_find(iterator, MESSAGE_KEY_CONDITIONS); + // Update display... +} + +// Request refresh every 30 minutes from tick_handler: +if (tick_time->tm_min % 30 == 0) { + DictionaryIterator *iter; + AppMessageResult result = app_message_outbox_begin(&iter); + if (result == APP_MSG_OK) { + dict_write_uint8(iter, MESSAGE_KEY_REQUEST_WEATHER, 1); + app_message_outbox_send(); + } +} +``` + +### JS Side Pattern (src/pkjs/index.js) +```javascript +// Use Open-Meteo API (free, no API key) +function getWeather() { + navigator.geolocation.getCurrentPosition(function(pos) { + var url = 'https://api.open-meteo.com/v1/forecast?' + + 'latitude=' + pos.coords.latitude + + '&longitude=' + pos.coords.longitude + + '¤t=temperature_2m,weather_code'; + // Fetch and send via Pebble.sendAppMessage()... + }); +} + +Pebble.addEventListener('ready', function() { getWeather(); }); +Pebble.addEventListener('appmessage', function(e) { + if (e.payload['REQUEST_WEATHER']) getWeather(); +}); +``` + +See `tutorials/c-watchface-tutorial/part4/` for a complete working example. + +### Visual Weather Reactions (C Side) + +The pkjs sends weather as human-readable strings ("Clear", "Cloudy", "Rain", etc.). To change visuals based on weather (sky color, particles, accessories), reverse-map the string to a numeric code on the C side: + +```c +static int s_weather_code = -1; // -1 = no data yet + +// In inbox_received_callback, after reading CONDITIONS: +const char *c = cond_tuple->value->cstring; +if (strcmp(c, "Clear") == 0) s_weather_code = 0; +else if (strcmp(c, "Cloudy") == 0) s_weather_code = 2; +else if (strcmp(c, "Rain") == 0 || strcmp(c, "Showers") == 0) s_weather_code = 63; +else if (strcmp(c, "Snow") == 0) s_weather_code = 73; +else if (strcmp(c, "Fog") == 0) s_weather_code = 45; +else if (strcmp(c, "T-Storm") == 0) s_weather_code = 95; +else s_weather_code = 2; + +// Then in draw functions, branch on s_weather_code: +if (s_weather_code == 0) { /* draw sun, blue sky */ } +else if (s_weather_code >= 61) { /* draw rain drops */ } +else if (s_weather_code >= 71) { /* draw snowflakes, white ground */ } +``` + +### Battery-Efficient Visual Variety + +Even with `MINUTE_UNIT` updates (no animation timer), you can create visual variety by using deterministic math tied to the minute counter. Each minute tick increments a frame counter, and drawing functions use it to offset positions: + +```c +static int s_frame = 0; // incremented in tick_handler + +// In draw function — "animated" rain/snow without a timer: +int rx = (i * 37 + s_frame * 7) % bounds.size.w; +int ry = 40 + (i * 23 + s_frame * 11) % sky_height; +``` + +This gives a different scene each minute without burning battery on sub-second redraws. + +--- + +## Tutorial Reference + +Complete working tutorial examples are in `tutorials/c-watchface-tutorial/`: + +| Part | What It Teaches | +|------|-----------------| +| part1 | Basic time + date display with system fonts | +| part4 | Weather via AppMessage + PebbleKit JS + Open-Meteo API | +| part6 | User settings via Clay configuration framework | + +These are sourced from [coredevices/c-watchface-tutorial](https://github.com/coredevices/c-watchface-tutorial). + +The Alloy equivalent is [coredevices/alloy-watchface-tutorial](https://github.com/coredevices/alloy-watchface-tutorial) (part1 basic Poco face → part2 custom fonts → part3 battery/BT → part4 weather via watch-side fetch → part5 Quick View → part6 Clay settings + localStorage). Its part1 is captured verbatim in `templates/alloy-*`. + +--- + +## Subagent Summary + +| Phase | Subagent Type | Purpose | +|-------|---------------|---------| +| Research | `Explore` | Read samples, tutorials, extract patterns | +| Design | `Plan` | Create implementation plan for emery (200x228) | +| Implement | Direct | `write_file()` all project sources | +| Build | Direct | `build()` | +| Test | Direct | `install()`, `screenshot()`, look at the image | +| Iterate | Direct | Fix code until the screenshot looks correct | +| Deliver | Direct | Describe the verified screenshot in chat | + +--- + +## Quick Reference + +### Emery Screen Dimensions (Default Target) +| Property | Value | +|----------|-------| +| Width | 200 px | +| Height | 228 px | +| Shape | Rectangular | +| Colors | 64-color | +| Center X | 100 | +| Center Y | 114 | + +### All Platform Dimensions +| Platform | Resolution | Shape | Color | +|----------|------------|-------|-------| +| emery | 200x228 | Rect | 64-color | +| gabbro | 260x260 | Round | 64-color | +| basalt | 144x168 | Rect | 64-color | +| chalk | 180x180 | Round | 64-color | +| aplite | 144x168 | Rect | B&W | +| diorite | 144x168 | Rect | B&W | +| flint | 144x168 | Rect | 64-color | + +### Key APIs +```c +// Drawing +graphics_fill_circle(ctx, center, radius); +graphics_draw_line(ctx, start, end); +graphics_fill_rect(ctx, rect, corner_radius, corners); +graphics_draw_arc(ctx, rect, scale_mode, angle_start, angle_end); +graphics_fill_radial(ctx, rect, scale_mode, inset, angle_start, angle_end); + +// Fixed-point trig (NO FLOATS!) +sin_lookup(angle); // 0 to TRIG_MAX_ANGLE (65536) +cos_lookup(angle); // returns -TRIG_MAX_RATIO to +TRIG_MAX_RATIO +DEG_TO_TRIGANGLE(degrees); // macro for conversion + +// Time — ALWAYS USE MINUTE_UNIT +tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); + +// Screen dimensions — use dynamically, don't hardcode +Layer *window_layer = window_get_root_layer(window); +GRect bounds = layer_get_bounds(window_layer); +// bounds.size.w = 200 on emery, bounds.size.h = 228 on emery + +// AppMessage (for weather/web data) +app_message_register_inbox_received(callback); +app_message_open(128, 128); +``` + +### Build & Test Commands + +There is no shell. These are tools, called directly. + +| Tool | Does | +|------|------| +| `build()` | Compile the project on the CloudPebble build farm, returns status + build log | +| `install()` | Install the last successful build into the user's running emulator | +| `screenshot()` | Capture the emulator screen, returned to you as an image | +| `press(button, hold_ms)` | up / select / down / back / shake — drive the app and screenshot the result | +| `logs(seconds)` | Drain app logs from the watch and console.log from the phone-side JS | +| `list_files()` | The project's current state: settings, platforms and their screen sizes, files, emulator | +| `write_resource(...)` | Add or replace an image, font or blob the app loads by resource id | +| `delete_resource(file_name)` | Remove a resource | +| `write_binary_file(path, content_base64)` | Alloy assets under `src/embeddedjs` (.png, .pdc, .ttf) | +| `set_app_settings(...)` | Change this project's settings. `app_is_watchface=true` is REQUIRED for a face | +| `read_file(path)` / `write_file(path, content)` / `delete_file(path)` | Edit sources by `project_path` | + +Preview GIFs, app icons, device deploys, `pebble login` and `pebble publish` are not +available in v1. Skip Phase 6 and Phase 8 entirely. + +### Emulator Interaction + +`press(button, hold_ms)` drives the watch: `up`, `select`, `down`, `back`, or `shake` +(an accelerometer tap). `hold_ms` defaults to 120; use ~700 for a long press. + +An interactive app is not verified until you have driven it — open it, scroll it, play a +turn — screenshotting as you go. A watchface has no buttons at all, so `shake` is its only +input. + +Touch is not available: emery and gabbro have touchscreens, but touch reaches the emulator +over VNC rather than the control channel these tools use. + +--- + +## Constraints + +1. **No Floating Point** — Use sin_lookup/cos_lookup only +2. **Pre-allocate Memory** — Create GPath in window_load for static shapes (clock hands, fixed elements). Small dynamic shapes that change position each frame (e.g. character silhouettes at computed coordinates) can use create/destroy in draw functions — this is acceptable for paths with ~3-6 points +3. **MINUTE_UNIT Only** — Never use SECOND_UNIT unless explicitly requested +4. **Clean Resources** — Destroy in unload handlers +5. **NULL Checks** — Verify pointers before use +6. **Overflow Protection** — Use modulo on counters +7. **Dynamic Bounds** — Use `layer_get_bounds()` not hardcoded screen sizes +8. **Register Before Open** — AppMessage callbacks must be registered before `app_message_open()` + +--- + +## File Checklist + +Before building (C project): +- [ ] `set_app_settings(app_is_watchface=...)` — true for a face, false for an app +- [ ] `src/c/main.c` with complete code +- [ ] `src/pkjs/index.js` (if weather/web data needed) +- [ ] Any needed project settings asked for in chat (platforms, messageKeys, capabilities) + +Before building (Alloy project): +- [ ] `set_app_settings(app_is_watchface=...)` +- [ ] `src/embeddedjs/main.js` with complete code +- [ ] `src/embeddedjs/manifest.json` — every extra module and font registered +- [ ] `src/c/mdbl.c` left exactly as it is +- [ ] `src/pkjs/index.js` with the pebbleproxy shim (if networking) +- [ ] `@moddable/pebbleproxy` — ask the user to add it in Dependencies (if networking) + +Build: `build()` +Test: `install()` +Screenshot: `screenshot()` diff --git a/cloudpebble-agent/skills/pebble-watchface/reference/alloy-guide.md b/cloudpebble-agent/skills/pebble-watchface/reference/alloy-guide.md new file mode 100644 index 0000000..4b25997 --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/reference/alloy-guide.md @@ -0,0 +1,371 @@ +# Alloy (JavaScript) Guide + +Alloy is Pebble's JavaScript framework: JS runs **on the watch** via the Moddable XS engine (ES6+/ES2025, strict mode, frozen primordials, no `eval`/`Function`). Marked by `"projectType": "moddable"` in package.json. + +**Platform support: emery and gabbro ONLY.** Any other target platform requires C. + +> **Inside CloudPebble:** a project is Alloy or C from creation — you cannot convert one, and +> there is no `package.json`, no `wscript` and no `pebble` CLI. CloudPebble builds Alloy for +> emery, gabbro and flint, and its scaffolding already created `src/c/mdbl.c`, +> `src/embeddedjs/main.js` and `src/embeddedjs/manifest.json`. Ignore every scaffolding and +> CLI instruction below; write the files with `write_file()` and build with `build()`. +> Dependencies like `@moddable/pebbleproxy` are added by the user in the Dependencies pane. + +## When to Use Alloy vs C + +| Use Alloy when... | Use C when... | +|---|---| +| Targeting only emery/gabbro | Targeting aplite/basalt/chalk/diorite/flint | +| Want `fetch`/`WebSocket`/`localStorage`/modern JS | Need HealthService, App Glances, timeline C APIs, background workers | +| Fast iteration on UI-heavy apps (Piu components) | Need maximum performance / tight memory control | +| Web-API-driven apps (networking is first-class) | Need MenuLayer/ActionBarLayer/system UI components (no Alloy equivalents — Piu builds UI from primitives) | +| Touchscreen interaction (emery/gabbro) | Games needing every frame of performance | + +Hot paths in an Alloy app can call C via FFI (see below). + +## Project Anatomy + +``` +project/ +├── package.json # "projectType": "moddable" +├── wscript # stock Pebble wscript — identical to C projects, never edit +├── src/ +│ ├── c/mdbl.c # boilerplate VM boot stub — never edit +│ ├── embeddedjs/ +│ │ ├── main.js # watch-side entry point (all watch logic) +│ │ ├── manifest.json # Moddable manifest: modules, font resources, FFI +│ │ └── assets/ # custom .ttf fonts etc. +│ └── pkjs/ +│ ├── index.js # phone-side JS: pebbleproxy for networking, Clay +│ └── config.js # Clay config (only if settings page) +``` + +Scaffold: `pebble new-project --alloy ` — or write files directly. + +### package.json (Alloy essentials) + +```json +{ + "name": "my-face", + "author": "Author", + "version": "1.0.0", + "keywords": ["pebble-app"], + "private": true, + "dependencies": {}, + "pebble": { + "displayName": "My Face", + "uuid": "GENERATE-NEW-UUID", + "projectType": "moddable", + "sdkVersion": "3", + "enableMultiJS": true, + "targetPlatforms": ["emery"], + "watchapp": { "watchface": true }, + "messageKeys": [], + "resources": { "media": [] } + } +} +``` + +- Watchapp: `"watchapp": { "watchface": false }`. +- Location sensor needs `"capabilities": ["location"]`; Clay settings need `"configurable"`. +- Networking: `"dependencies": { "@moddable/pebbleproxy": "^0.1.7" }` (install via `pebble package install @moddable/pebbleproxy`). +- Clay: `"@rebble/clay": "^1.0.8"`. + +### src/c/mdbl.c (verbatim, never changes) + +```c +#include + +int main(void) { + Window *w = window_create(); + window_stack_push(w, true); + + moddable_createMachine(NULL); + + window_destroy(w); +} +``` + +### src/embeddedjs/manifest.json (minimal) + +```json +{ + "include": ["$(MODDABLE)/examples/manifest_mod.json"], + "modules": { "*": "./main.js" } +} +``` + +**Every extra JS module file must be registered** or you get module-not-found at runtime: `"modules": { "*": ["./main", "./math"] }`. + +### wscript + +Use the stock Pebble wscript (same as C projects — the one in the skill templates). Nothing Alloy-specific. + +## Poco Rendering (procedural graphics) + +```javascript +import Poco from "commodetto/Poco"; +const render = new Poco(screen); // `screen` is a global + +render.width; render.height; // emery: 200x228 +render.unobstructed.width / .height; // area not covered by Timeline Quick View + +const white = render.makeColor(255, 255, 255); // reuse color objects +const font = new render.Font("Gothic-Bold", 24); // system font + +render.begin(); // or begin(x, y, w, h) to clip = faster +render.fillRectangle(color, x, y, w, h); +render.drawRoundRect(x, y, w, h, color, radius); // corners bitmask optional +render.frameRoundRect(x, y, w, h, color, radius); // outline +render.drawLine(x1, y1, x2, y2, color, thickness); +render.drawCircle(color, cx, cy, radius, startAngle, endAngle); // degrees — FILLED pie slice, NOT a stroked arc +render.drawText(str, font, color, x, y); +const w = render.getTextWidth(str, font); // font.height for vertical layout +render.end(); +``` + +**System fonts — name AND size must match a real system font. An invalid combination (e.g. `Bitham-Bold` at 48) builds fine but kills the JS VM at launch: white screen, app exits instantly, NO error in logs (only a "Heap Usage for App" exit line). White screen at launch = check your font sizes first.** + +Valid system font sizes (from the Pebble system font set): + +| Family | Valid sizes | +|---|---| +| Gothic-Regular / Gothic-Bold | 14, 18, 24, 28 | +| Bitham-Bold | 42 only | +| Bitham-Black | 30 only | +| Bitham-Light | 42 | +| Roboto-Condensed | 21 | +| Leco-Regular (numbers) | 20, 26, 28, 32, 36, 38, 42 | +| Droid-Serif | 28 | + +For anything else, use a custom TTF (next section). + +Bitmaps/vectors: `new Poco.PebbleBitmap(resourceId)` + `render.drawBitmap(bmp, x, y)`; PDC vector via `new Poco.PebbleDrawCommandImage(resourceId)` + `render.drawDCI(pdc, x, y)` with `pdc.clone()/rotate(rad, px, py)/scale(f)`; animated sequences via `Poco.PebbleDrawCommandSequence`. + +Frame pattern: every redraw = `begin()` → fill full-screen background → draw → `end()`. + +No stroked-circle/ring primitive. Ring (e.g. orbit line): draw filled circle in ring color, then punch out a smaller filled circle in the background color. Arc outline: same trick with `startAngle`/`endAngle` on both circles. + +## Watchface Events + +```javascript +watch.addEventListener("minutechange", e => draw(e.date)); // ALWAYS prefer over secondchange +watch.addEventListener("hourchange", e => refetch()); +watch.addEventListener("connected", checkConnection); // watch.connected.app boolean +watch.addEventListener("resize", drawScreen); // Quick View appear/disappear +``` + +- **Time listeners (`minutechange`, `hourchange`) fire immediately on registration** — that IS the initial draw/fetch; do not add a separate startup call. `connected`/`resize` are NOT confirmed to fire immediately — read `watch.connected.app` directly at startup for initial state. +- Handlers may be invoked without an event (from your own code); use `const now = event?.date ?? lastDate;` pattern. +- Watchface Up/Down buttons reserved for timeline; use accelerometer taps for input. +- Quick View: clear background with `render.width/height`, position content with `render.unobstructed.*`, recompute layout inside the draw function, listen to `"resize"`. + +## Custom Fonts + +1. Put TTF in `src/embeddedjs/assets/`. +2. Declare in manifest.json: + +```json +"resources": { + "*-alpha": [ + { "source": "./assets/Jersey10-Regular", "size": 56, "monochrome": true, "blocks": ["Basic Latin"] } + ] +} +``` + +3. Load in main.js: + +```javascript +import parseBMF from "commodetto/parseBMF"; +import parseRLE from "commodetto/parseRLE"; +function getFont(name, size) { + const font = parseBMF(new Resource(`${name}-${size}.fnt`)); + font.bitmap = parseRLE(new Resource(`${name}-${size}-alpha.bm4`)); + return font; +} +const timeFont = getFont("Jersey10-Regular", 56); +``` + +`"blocks": ["Basic Latin"]` subsets characters to save memory; `"monochrome": true` for crisp 1-bit rendering. + +## Sensors & Input (ECMA-419 style) + +```javascript +import Battery from "embedded:sensor/Battery"; +const battery = new Battery({ onSample() { pct = this.sample().percent; draw(); } }); +pct = battery.sample().percent; // immediate read; keep instance open (do NOT close) + +import Location from "embedded:sensor/Location"; // needs "location" capability + pebbleproxy +new Location({ onSample() { + const s = this.sample(); // s.latitude, s.longitude + this.close(); // Location is ONE-SHOT: must close after read + fetchWeather(s.latitude, s.longitude); +}}); + +import Accelerometer from "embedded:sensor/Accelerometer"; +new Accelerometer({ onSample() {}, onTap(dir) {}, onDoubleTap(dir) {} }); // sample() → {x,y,z} + +import Compass from "embedded:sensor/Compass"; // sample() → {heading} 0-360 + +import Button from "pebble/button"; // WATCHAPPS ONLY +new Button({ types: ["select","up","down","back"], onPush(down, type) {} }); // down: 1=pressed 0=released + +// Touch (emery/gabbro): feature-detect first +const hasTouch = device.sensor.Touch ? true : false; +new device.sensor.Touch({ onSample() {} }); // sample() → array of {x,y} or falsy +``` + +## Networking (proxied through phone) + +Watch has no direct internet. Setup: + +```bash +pebble package install @moddable/pebbleproxy +``` + +`src/pkjs/index.js`: + +```javascript +const moddableProxy = require("@moddable/pebbleproxy"); +Pebble.addEventListener('ready', moddableProxy.readyReceived); +Pebble.addEventListener('appmessage', moddableProxy.appMessageReceived); +``` + +Then watch-side gets standard web APIs: + +```javascript +const url = new URL("https://api.open-meteo.com/v1/forecast"); +url.search = new URLSearchParams({ latitude, longitude, current: "temperature_2m,weather_code" }); +const response = await fetch(url); // response.ok, .status, .json(), .text() +const data = await response.json(); +``` + +- Wrap in try/catch; requires connected phone with internet. +- Wait for `watch.connected.pebblekit === true` before fetch/WebSocket. If not connected yet, retry in ~1s rather than failing. +- **First fetch after launch can fail transiently even when connected.** Always auto-retry (~10s) on failure — a face that only refetches on `hourchange` would otherwise pin "NO LINK" on screen for an hour. +- `WebSocket` also available; low-level `embedded:network/http/client` / `websocket/client` for streaming. + +### Known fetch() crash + robust fallback + +`fetch()` can hard-crash the XS VM intermittently: the pebbleproxy http client splits proxied header lines on `":"`, and a colon-less line yields `undefined`, which `Headers.prototype.set` calls `.toString()` on → `TypeError: cannot coerce undefined to object` → `fxAbort`. **Uncatchable from app code** (fires inside the transport callback); app exits at fetch time with that TypeError in logs. + +If you hit it (or want immunity for a production app), bypass fetch with the raw client — `headersMask` avoids the broken header path: + +```javascript +let client = null; +function httpGetJSON(host, path, onSuccess, onFail) { + let watchdog = setTimeout(() => fail("timeout"), 20000); // fail(), not finish(): must close the stuck client so retry gets a fresh one + const chunks = []; + let httpStatus = 0; + function finish(cb, arg) { + if (!watchdog) return; + clearTimeout(watchdog); watchdog = null; + cb(arg); + } + function fail(e) { + try { client?.close(); } catch (_) {} + client = null; + finish(onFail, e); + } + try { + client ??= new device.network.https.io({ + ...device.network.https, host, port: 443, + onError(e) { fail(e); } + }); + client.request({ + path, + headersMask: ["content-length"], + onHeaders(status) { httpStatus = status; }, + onReadable(count) { if (count) chunks.push(String.fromArrayBuffer(this.read())); }, + onDone() { + if (httpStatus < 200 || httpStatus > 299) return finish(onFail, "http " + httpStatus); + try { finish(onSuccess, JSON.parse(chunks.join(""))); } catch (e) { finish(onFail, e); } + }, + onError(e) { fail(e); } + }); + } catch (e) { fail(e); } +} +``` + +Still requires the pebbleproxy pkjs shim; only the watch-side API changes. + +## Storage + +- `localStorage` (strings only): `JSON.stringify`/`parse`, merge defaults via spread `{...DEFAULTS, ...JSON.parse(stored)}`, try/catch the parse. +- `device.keyValue.open({path, format})` → `write/read/delete/close`. +- `device.files.openFile({path, mode, size})` for binary files. + +## Settings (Clay) + AppMessage + +pkjs side: standard Clay (`@rebble/clay` + config.js). Watch side: + +```javascript +import Message from "pebble/message"; +const message = new Message({ + keys: ["BackgroundColor", "ShowDate"], // must match package.json messageKeys AND config.js messageKey + onReadable() { + const msg = this.read(); + const bg = msg.get("BackgroundColor"); // colors: 24-bit int → (bg>>16)&0xFF, (bg>>8)&0xFF, bg&0xFF + const show = msg.get("ShowDate"); // toggles: 0/1 + } +}); +message.write(new Map([["COMMAND", 1]])); // send to phone +``` + +Coexisting with pebbleproxy in pkjs: `if (moddableProxy.appMessageReceived(e)) return;` + +## Other Services + +```javascript +import Vibes from "pebble/vibes"; // shortPulse/longPulse/doublePulse/pattern([ms,...])/cancel +import WakeUp from "pebble/wakeup"; // schedule(timeMs, cookie, notifyIfMissed) → id; watch.wake at launch +import Dictation from "pebble/dictation"; // needs phone+internet +watch.light(true|false); // backlight; watch.light() = auto +// Device info: watch.model, watch.firmwareVersion, watch.hour12, watch.launch +// Screen: screen.width/height/round/color +``` + +## Timers & Animation + +- `setInterval`/`setTimeout`/`setImmediate` work. Animation loop: `setInterval(draw, 33)` ≈ 30fps (17ms ≈ 60fps costs battery). Stop timers when done. +- Piu Timeline for tweens: `import Timeline from "piu/Timeline"` + easing on `Math.*Ease*` (quadEaseOut, bounceEaseOut, etc.). UI transitions 200–500ms. + +## Piu (declarative UI — alternative to Poco) + +`import {} from "piu/MC";` gives Application/Content/Label/Text/Container/Column/Row/Skin/Style/Behavior globals. Use for component-based watchapp UIs; use Poco for pixel control. Port bridges both (`onDraw` callback inside Piu layout). See https://developer.repebble.com/guides/alloy/piu-guide/ if needed — Poco is simpler and covers most watchface/game cases. + +## FFI (call C from JS) + +C file next to mdbl.c; declare in manifest.json `"ffi": {"sources": [...], "functions": {"add": {"arguments": ["int32_t","int32_t"], "returns": "int32_t"}}}`; call via global `Natives.add(2, 3)`. Requires mdbl.c modification to pass `.fxBuildFFI` — only for hot paths. + +## Build & Test + +```bash +pebble build +pebble install --emulator emery +pebble logs --emulator emery # console.log output +pebble screenshot --no-open --emulator emery shot.png +pebble emu-battery --percent 30 +pebble emu-bt-connection --connected no +pebble emu-set-timeline-quick-view on +pebble emu-app-config # open Clay page +pebble package install # npm-style pebble packages +``` + +## Gotchas Checklist + +1. Alloy = emery/gabbro only. Check targetPlatforms. +2. `projectType: "moddable"` required; `watchapp.watchface: true` for faces. +3. `minutechange` not `secondchange` (battery). +4. Time events fire immediately on registration — no separate initial draw. +5. Extra JS modules must be in manifest.json `modules`. +6. Custom fonts need manifest `resources["*-alpha"]` declaration + parseBMF/parseRLE load with exact `Name-Size.fnt`/`Name-Size-alpha.bm4` names. +7. `Location` one-shot → `close()`; `Battery` stays open. +8. `fetch` requires pebbleproxy in pkjs + connected phone; wait for `watch.connected.pebblekit`. +9. Redraw handlers called without event → `event?.date ?? lastDate`. +10. `| 0` to truncate float math to ints (perf). +11. Quick View: clear with `render.width/height`, position with `render.unobstructed.*`, handle `"resize"`. +12. Clay keys must match in 3 places: package.json messageKeys, config.js messageKey, watch Message keys. +13. localStorage = strings only; spread-merge defaults. +14. No eval/Function; primordials frozen; strict mode always. +15. Emery vs gabbro: branch on `screen.round` or `render.unobstructed.height`. diff --git a/cloudpebble-agent/skills/pebble-watchface/reference/animation-patterns.md b/cloudpebble-agent/skills/pebble-watchface/reference/animation-patterns.md new file mode 100644 index 0000000..9398d0b --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/reference/animation-patterns.md @@ -0,0 +1,391 @@ +# Animation Patterns for Pebble Watchfaces + +## Animation Loop Structure + +### Basic Animation Timer +```c +static AppTimer *s_animation_timer = NULL; +#define ANIMATION_INTERVAL 50 // 20 FPS + +static void animation_timer_callback(void *data) { + // Update animation state + update_animation(); + + // Request redraw + layer_mark_dirty(s_canvas_layer); + + // Schedule next frame + s_animation_timer = app_timer_register(ANIMATION_INTERVAL, animation_timer_callback, NULL); +} + +// Start in window_load: +s_animation_timer = app_timer_register(ANIMATION_INTERVAL, animation_timer_callback, NULL); + +// Cancel in window_unload: +if (s_animation_timer) { + app_timer_cancel(s_animation_timer); + s_animation_timer = NULL; +} +``` + +### Battery-Aware Animation +```c +#define ANIMATION_INTERVAL 50 +#define ANIMATION_INTERVAL_LOW_POWER 100 +#define LOW_BATTERY_THRESHOLD 20 + +static int s_battery_level = 100; + +static void animation_timer_callback(void *data) { + update_animation(); + layer_mark_dirty(s_canvas_layer); + + uint32_t interval = (s_battery_level <= LOW_BATTERY_THRESHOLD) + ? ANIMATION_INTERVAL_LOW_POWER + : ANIMATION_INTERVAL; + + s_animation_timer = app_timer_register(interval, animation_timer_callback, NULL); +} + +static void battery_callback(BatteryChargeState state) { + s_battery_level = state.charge_percent; +} +``` + +## Common Animation Patterns + +### Oscillating Motion (Wave/Sway) +```c +typedef struct { + GPoint pos; + int32_t phase; // Animation phase + int speed; +} Swaying; + +static void update_sway(Swaying *obj) { + // Increment phase with overflow protection + obj->phase = (obj->phase + obj->speed * 100) % TRIG_MAX_ANGLE; +} + +static void draw_sway(GContext *ctx, Swaying *obj) { + // Calculate offset using sine + int16_t offset = (sin_lookup(obj->phase) * 5) / TRIG_MAX_RATIO; + + GPoint draw_pos = { + .x = obj->pos.x + offset, + .y = obj->pos.y + }; + + graphics_fill_circle(ctx, draw_pos, 5); +} +``` + +### Linear Movement with Wrapping +```c +typedef struct { + GPoint pos; + int direction; // 1 or -1 + int speed; + bool active; +} Moving; + +// Use screen_w and screen_h from layer_get_bounds() — don't hardcode! +static void update_moving(Moving *obj, int screen_w, int screen_h) { + if (!obj->active) return; + + obj->pos.x += obj->direction * obj->speed; + + // Wrap around screen edges (use dynamic screen dimensions) + if (obj->direction == 1 && obj->pos.x > screen_w + 10) { + obj->pos.x = -10; + obj->pos.y = random_in_range(20, screen_h - 30); + } else if (obj->direction == -1 && obj->pos.x < -10) { + obj->pos.x = screen_w + 10; + obj->pos.y = random_in_range(20, screen_h - 30); + } +} +``` + +### Pulsing Effect +```c +typedef struct { + GPoint pos; + int pulse_state; // 0 to 100 + int base_size; +} Pulsing; + +static void update_pulse(Pulsing *obj) { + obj->pulse_state = (obj->pulse_state + 2) % 100; +} + +static void draw_pulse(GContext *ctx, Pulsing *obj) { + // Size oscillates between base_size and base_size + 4 + int size_offset = (obj->pulse_state < 50) + ? obj->pulse_state / 10 + : (100 - obj->pulse_state) / 10; + + int size = obj->base_size + size_offset; + graphics_fill_circle(ctx, obj->pos, size); +} +``` + +### Rising Particles (Bubbles) +```c +typedef struct { + GPoint pos; + int size; + int speed; + bool active; +} Particle; + +#define MAX_PARTICLES 8 +static Particle particles[MAX_PARTICLES]; + +// Pass screen dimensions from layer_get_bounds() +static void init_particle(Particle *p, int screen_w, int screen_h) { + p->pos.x = random_in_range(10, screen_w - 10); + p->pos.y = screen_h; // Start at bottom + p->size = random_in_range(1, 3); + p->speed = random_in_range(1, 3); + p->active = true; +} + +static void update_particles(void) { + for (int i = 0; i < MAX_PARTICLES; i++) { + if (particles[i].active) { + particles[i].pos.y -= particles[i].speed; + + // Slight horizontal wobble + if (random_in_range(0, 2) == 0) { + particles[i].pos.x += random_in_range(-1, 1); + } + + // Deactivate when off screen + if (particles[i].pos.y < 0) { + particles[i].active = false; + } + } else { + // Random chance to spawn new particle + if (random_in_range(0, 100) < 2) { + init_particle(&particles[i]); + } + } + } +} +``` + +### Tentacle/Wavy Line Animation +```c +static void draw_wavy_line(GContext *ctx, GPoint start, int length, + int segments, int32_t phase) { + GPoint current = start; + GPoint next; + + for (int i = 0; i < segments; i++) { + int32_t angle = (phase + (i * 1500)) % TRIG_MAX_ANGLE; + int16_t offset = (sin_lookup(angle) * 3) / TRIG_MAX_RATIO; + + next.x = current.x + offset; + next.y = current.y + (length / segments); + + graphics_draw_line(ctx, current, next); + current = next; + } +} +``` + +## Collision Detection + +### Circle-Circle Collision +```c +static bool check_collision(GPoint pos1, int radius1, GPoint pos2, int radius2) { + int dx = pos1.x - pos2.x; + int dy = pos1.y - pos2.y; + int distance_squared = (dx * dx) + (dy * dy); + int radius_sum = radius1 + radius2; + return distance_squared <= (radius_sum * radius_sum); +} +``` + +### Spatial Grid Optimization +For many moving objects, use a spatial grid to reduce collision checks from O(n²) to O(n): + +```c +// Use dynamic screen dimensions from layer_get_bounds() +#define GRID_WIDTH 3 +#define GRID_HEIGHT 3 +// Calculate cell sizes at runtime using bounds.size.w / GRID_WIDTH +#define GRID_CELL_COUNT (GRID_WIDTH * GRID_HEIGHT) + +static int objects_in_grid[GRID_CELL_COUNT][MAX_OBJECTS]; +static int grid_counts[GRID_CELL_COUNT]; + +static int get_grid_cell(GPoint point) { + int x = point.x / GRID_CELL_WIDTH; + int y = point.y / GRID_CELL_HEIGHT; + + // Clamp to valid range + if (x < 0) x = 0; + if (x >= GRID_WIDTH) x = GRID_WIDTH - 1; + if (y < 0) y = 0; + if (y >= GRID_HEIGHT) y = GRID_HEIGHT - 1; + + return y * GRID_WIDTH + x; +} + +static void update_spatial_grid(void) { + // Clear grid + for (int i = 0; i < GRID_CELL_COUNT; i++) { + grid_counts[i] = 0; + } + + // Place objects in cells + for (int i = 0; i < MAX_OBJECTS; i++) { + if (objects[i].active) { + int cell = get_grid_cell(objects[i].pos); + if (grid_counts[cell] < MAX_OBJECTS) { + objects_in_grid[cell][grid_counts[cell]] = i; + grid_counts[cell]++; + } + } + } +} + +// Only check collisions with objects in same/adjacent cells +static void check_collisions_optimized(int object_index) { + int cell = get_grid_cell(objects[object_index].pos); + + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + int check_x = (cell % GRID_WIDTH) + dx; + int check_y = (cell / GRID_WIDTH) + dy; + + if (check_x < 0 || check_x >= GRID_WIDTH) continue; + if (check_y < 0 || check_y >= GRID_HEIGHT) continue; + + int check_cell = check_y * GRID_WIDTH + check_x; + + for (int i = 0; i < grid_counts[check_cell]; i++) { + int other = objects_in_grid[check_cell][i]; + if (other == object_index) continue; + + if (check_collision(objects[object_index].pos, 5, + objects[other].pos, 5)) { + // Handle collision + } + } + } + } +} +``` + +## Analog Clock Hands + +### Drawing Clock Hands +```c +static void draw_hand(GContext *ctx, GPoint center, int length, + int32_t angle, int width) { + int16_t x = center.x + (sin_lookup(angle) * length) / TRIG_MAX_RATIO; + int16_t y = center.y - (cos_lookup(angle) * length) / TRIG_MAX_RATIO; + + graphics_context_set_stroke_width(ctx, width); + graphics_draw_line(ctx, center, GPoint(x, y)); +} + +static void draw_clock_hands(GContext *ctx, struct tm *time, GPoint center) { + // Hour hand + int32_t hour_angle = ((time->tm_hour % 12) * TRIG_MAX_ANGLE / 12) + + (time->tm_min * TRIG_MAX_ANGLE / 12 / 60); + draw_hand(ctx, center, 30, hour_angle, 4); + + // Minute hand + int32_t min_angle = (time->tm_min * TRIG_MAX_ANGLE / 60); + draw_hand(ctx, center, 45, min_angle, 2); + + // Second hand + int32_t sec_angle = (time->tm_sec * TRIG_MAX_ANGLE / 60); + draw_hand(ctx, center, 50, sec_angle, 1); + + // Center dot + graphics_fill_circle(ctx, center, 4); +} +``` + +## Memory-Efficient Path Animation + +Pre-allocate path objects and update points in-place: + +```c +static GPoint tail_points[3]; +static GPath *tail_path = NULL; + +// In window_load: +static GPathInfo tail_info = { + .num_points = 3, + .points = tail_points +}; +tail_path = gpath_create(&tail_info); + +// In draw function (update points, don't recreate path): +static void draw_fish_tail(GContext *ctx, GPoint pos, int direction) { + tail_points[0].x = pos.x; + tail_points[0].y = pos.y; + tail_points[1].x = pos.x - (direction * 10); + tail_points[1].y = pos.y - 5; + tail_points[2].x = pos.x - (direction * 10); + tail_points[2].y = pos.y + 5; + + gpath_draw_filled(ctx, tail_path); +} + +// In window_unload: +if (tail_path) { + gpath_destroy(tail_path); + tail_path = NULL; +} +``` + +## State Machine Animation + +For complex animations with multiple states: + +```c +typedef enum { + STATE_IDLE, + STATE_MOVING, + STATE_ATTACKING, + STATE_FLEEING +} AnimState; + +typedef struct { + GPoint pos; + AnimState state; + int state_timer; + int direction; +} Creature; + +// Pass screen_w from layer_get_bounds().size.w +static void update_creature(Creature *c, int screen_w) { + c->state_timer++; + + switch (c->state) { + case STATE_IDLE: + if (c->state_timer > 100) { + c->state = STATE_MOVING; + c->state_timer = 0; + c->direction = random_in_range(0, 1) ? 1 : -1; + } + break; + + case STATE_MOVING: + c->pos.x += c->direction * 2; + if (c->state_timer > 50 || c->pos.x < 10 || c->pos.x > screen_w - 10) { + c->state = STATE_IDLE; + c->state_timer = 0; + } + break; + + // ... other states + } +} +``` diff --git a/cloudpebble-agent/skills/pebble-watchface/reference/drawing-guide.md b/cloudpebble-agent/skills/pebble-watchface/reference/drawing-guide.md new file mode 100644 index 0000000..040fac7 --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/reference/drawing-guide.md @@ -0,0 +1,278 @@ +# Pebble Drawing Guide + +## Screen Coordinates + +``` +(0,0) ────────────────────→ X + │ + │ ┌─────────────┐ + │ │ │ + │ │ Screen │ + │ │ │ + │ │ │ + │ └─────────────┘ + ↓ + Y +``` + +### Platform Dimensions +| Platform | Width | Height | Shape | +|----------|-------|--------|-------| +| emery | 200 | 228 | Rect | +| gabbro | 260 | 260 | Round | +| basalt | 144 | 168 | Rect | +| chalk | 180 | 180 | Round | +| aplite | 144 | 168 | Rect | +| diorite | 144 | 168 | Rect | +| flint | 144 | 168 | Rect | + +**Always use `layer_get_bounds()` to get screen dimensions dynamically.** Do not hardcode pixel values. + +```c +Layer *window_layer = window_get_root_layer(window); +GRect bounds = layer_get_bounds(window_layer); +int width = bounds.size.w; // 200 on emery, 260 on gabbro, etc. +int height = bounds.size.h; // 228 on emery, 260 on gabbro, etc. +``` + +## Basic Shapes + +### Circles +```c +// Filled circle +graphics_context_set_fill_color(ctx, GColorWhite); +graphics_fill_circle(ctx, GPoint(bounds.size.w / 2, bounds.size.h / 2), 20); + +// Outlined circle +graphics_context_set_stroke_color(ctx, GColorWhite); +graphics_draw_circle(ctx, GPoint(bounds.size.w / 2, bounds.size.h / 2), 20); +``` + +### Rectangles +```c +GRect rect = GRect(10, 10, 50, 30); // x, y, width, height + +// Filled rectangle +graphics_fill_rect(ctx, rect, 0, GCornerNone); // Sharp corners + +// Rounded rectangle +graphics_fill_rect(ctx, rect, 5, GCornersAll); // 5px corner radius + +// Only round specific corners +graphics_fill_rect(ctx, rect, 5, GCornersTop); +``` + +### Lines +```c +graphics_context_set_stroke_color(ctx, GColorWhite); +graphics_context_set_stroke_width(ctx, 2); +graphics_draw_line(ctx, GPoint(10, 10), GPoint(100, 100)); +``` + +### Arcs and Radial Fills +```c +// Draw an arc (ring outline) +graphics_draw_arc(ctx, bounds, GOvalScaleModeFitCircle, + DEG_TO_TRIGANGLE(0), DEG_TO_TRIGANGLE(270)); + +// Fill a radial segment (pie slice / ring segment) +graphics_fill_radial(ctx, bounds, GOvalScaleModeFitCircle, + 10, // inset thickness + DEG_TO_TRIGANGLE(0), DEG_TO_TRIGANGLE(270)); + +// Get a point on the circle perimeter +GPoint p = gpoint_from_polar(bounds, GOvalScaleModeFitCircle, + DEG_TO_TRIGANGLE(45)); +``` + +## Paths (Complex Shapes) + +### Triangle +```c +static GPoint triangle_points[3]; +static GPathInfo triangle_info = { + .num_points = 3, + .points = triangle_points +}; +static GPath *triangle = NULL; + +// In window_load - set points relative to screen +triangle_points[0] = GPoint(bounds.size.w / 2, 20); // Top center +triangle_points[1] = GPoint(20, bounds.size.h - 20); // Bottom left +triangle_points[2] = GPoint(bounds.size.w - 20, bounds.size.h - 20); // Bottom right +triangle = gpath_create(&triangle_info); + +// In update_proc +graphics_context_set_fill_color(ctx, GColorWhite); +gpath_draw_filled(ctx, triangle); + +// In window_unload +gpath_destroy(triangle); +``` + +### Star +```c +static GPoint star_points[10]; + +static void init_star(GPoint center, int outer_r, int inner_r) { + for (int i = 0; i < 10; i++) { + int32_t angle = (i * TRIG_MAX_ANGLE / 10) - (TRIG_MAX_ANGLE / 4); + int radius = (i % 2 == 0) ? outer_r : inner_r; + star_points[i].x = center.x + (sin_lookup(angle) * radius) / TRIG_MAX_RATIO; + star_points[i].y = center.y + (cos_lookup(angle) * radius) / TRIG_MAX_RATIO; + } +} +``` + +## Text Rendering + +### Using Text Layers +```c +static TextLayer *s_time_layer; + +// Create — use bounds for positioning +s_time_layer = text_layer_create(GRect(0, 50, bounds.size.w, 40)); +text_layer_set_background_color(s_time_layer, GColorClear); +text_layer_set_text_color(s_time_layer, GColorWhite); +text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD)); +text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter); +layer_add_child(window_layer, text_layer_get_layer(s_time_layer)); +``` + +### Drawing Text Directly +```c +static void draw_text(GContext *ctx, const char *text, GRect text_bounds) { + graphics_context_set_text_color(ctx, GColorWhite); + graphics_draw_text(ctx, text, + fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD), + text_bounds, + GTextOverflowModeWordWrap, + GTextAlignmentCenter, + NULL); +} +``` + +## Color Management + +### Platform-Aware Colors +```c +#ifdef PBL_COLOR + #define BACKGROUND_COLOR GColorDarkGray + #define FOREGROUND_COLOR GColorCyan + #define ACCENT_COLOR GColorRed +#else + #define BACKGROUND_COLOR GColorBlack + #define FOREGROUND_COLOR GColorWhite + #define ACCENT_COLOR GColorWhite +#endif +``` + +### Color Palette (64 colors on color platforms) +```c +// Primary +GColorRed, GColorGreen, GColorBlue + +// Warm +GColorOrange, GColorYellow, GColorRajah, GColorMelon + +// Cool +GColorCyan, GColorTiffanyBlue, GColorCadetBlue, GColorPictonBlue + +// Purple/Pink +GColorMagenta, GColorPurple, GColorVividViolet, GColorShockingPink + +// Neutrals +GColorBlack, GColorOxfordBlue, GColorDarkGray, GColorLightGray, GColorWhite +``` + +## Drawing Order (Painter's Algorithm) + +Draw from back to front: + +```c +static void canvas_update_proc(Layer *layer, GContext *ctx) { + GRect bounds = layer_get_bounds(layer); + + // 1. Clear background + graphics_context_set_fill_color(ctx, GColorBlack); + graphics_fill_rect(ctx, bounds, 0, GCornerNone); + + // 2. Draw background elements + draw_background(ctx, bounds); + + // 3. Draw middle-ground elements + draw_scenery(ctx, bounds); + + // 4. Draw foreground elements + draw_characters(ctx, bounds); + + // 5. Draw UI overlays last + draw_ui(ctx, bounds); +} +``` + +## Common Drawing Patterns + +### Battery Bar +```c +static void draw_battery_bar(GContext *ctx, GRect bar_bounds, int percent) { + // Outline + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_draw_rect(ctx, bar_bounds); + + // Fill + int fill_width = (bar_bounds.size.w * percent) / 100; + GRect fill = {bar_bounds.origin, {fill_width, bar_bounds.size.h}}; + graphics_context_set_fill_color(ctx, GColorWhite); + graphics_fill_rect(ctx, fill, 0, GCornerNone); + + // Battery tip + GRect tip = { + {bar_bounds.origin.x + bar_bounds.size.w, bar_bounds.origin.y + 2}, + {2, bar_bounds.size.h - 4} + }; + graphics_fill_rect(ctx, tip, 0, GCornerNone); +} +``` + +### Analog Clock Face +```c +static void draw_clock_face(GContext *ctx, GPoint center, int radius) { + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_context_set_stroke_width(ctx, 2); + graphics_draw_circle(ctx, center, radius); + + // Hour markers + for (int i = 0; i < 12; i++) { + int32_t angle = (i * TRIG_MAX_ANGLE) / 12; + int inner_r = radius - 8; + int outer_r = radius - 2; + + GPoint inner = { + center.x + (sin_lookup(angle) * inner_r) / TRIG_MAX_RATIO, + center.y - (cos_lookup(angle) * inner_r) / TRIG_MAX_RATIO + }; + GPoint outer = { + center.x + (sin_lookup(angle) * outer_r) / TRIG_MAX_RATIO, + center.y - (cos_lookup(angle) * outer_r) / TRIG_MAX_RATIO + }; + graphics_draw_line(ctx, inner, outer); + } +} +``` + +## Performance Tips + +1. **Use `layer_get_bounds()`** — Get dimensions dynamically, never hardcode +2. **Minimize draw calls** — Batch similar operations +3. **Pre-calculate positions** — Don't do math in draw functions if avoidable +4. **Use `layer_mark_dirty()`** — Only redraw when necessary +5. **Clip to visible area** — Skip drawing objects outside screen bounds + +```c +// Check if point is on screen before drawing (uses dynamic bounds) +static bool is_visible(GPoint p, GRect bounds, int margin) { + return p.x >= -margin && p.x <= bounds.size.w + margin && + p.y >= -margin && p.y <= bounds.size.h + margin; +} +``` diff --git a/cloudpebble-agent/skills/pebble-watchface/reference/pebble-api-reference.md b/cloudpebble-agent/skills/pebble-watchface/reference/pebble-api-reference.md new file mode 100644 index 0000000..fa32730 --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/reference/pebble-api-reference.md @@ -0,0 +1,556 @@ +# Pebble API Reference + +## Core Header +```c +#include +``` + +## Data Types + +### Points and Rectangles +```c +typedef struct { + int16_t x; + int16_t y; +} GPoint; + +typedef struct { + GPoint origin; + GSize size; +} GRect; + +typedef struct { + int16_t w; + int16_t h; +} GSize; +``` + +### Colors +```c +// Basic colors (work on all platforms) +GColorBlack +GColorWhite +GColorClear // Transparent + +// Color display colors (basalt, chalk, emery, gabbro, flint) +GColorRed, GColorGreen, GColorBlue +GColorYellow, GColorCyan, GColorMagenta +GColorOrange, GColorPurple, GColorPink +// ... and many more (64 colors total) + +// Create custom color +GColorFromRGB(r, g, b) // r, g, b: 0-255 +GColorFromHEX(0xRRGGBB) +``` + +## Graphics Context + +### Setting Colors +```c +graphics_context_set_fill_color(GContext *ctx, GColor color); +graphics_context_set_stroke_color(GContext *ctx, GColor color); +graphics_context_set_stroke_width(GContext *ctx, uint8_t width); // odd values only +graphics_context_set_text_color(GContext *ctx, GColor color); +graphics_context_set_antialiased(GContext *ctx, bool enable); // default true +``` + +### Drawing Shapes +```c +// Circles +graphics_fill_circle(GContext *ctx, GPoint center, uint16_t radius); +graphics_draw_circle(GContext *ctx, GPoint center, uint16_t radius); + +// Rectangles +graphics_fill_rect(GContext *ctx, GRect rect, uint16_t corner_radius, GCornerMask corners); +graphics_draw_rect(GContext *ctx, GRect rect); +graphics_draw_round_rect(GContext *ctx, GRect rect, uint16_t radius); + +// Lines +graphics_draw_line(GContext *ctx, GPoint start, GPoint end); + +// Pixels +graphics_draw_pixel(GContext *ctx, GPoint point); + +// Arcs and radial fills +graphics_draw_arc(GContext *ctx, GRect rect, GOvalScaleMode scale_mode, + int32_t angle_start, int32_t angle_end); +graphics_fill_radial(GContext *ctx, GRect rect, GOvalScaleMode scale_mode, + uint16_t inset_thickness, int32_t angle_start, int32_t angle_end); + +// Polar coordinate helpers +GPoint gpoint_from_polar(GRect rect, GOvalScaleMode scale_mode, int32_t angle); +GRect grect_centered_from_polar(GRect rect, GOvalScaleMode scale_mode, + int32_t angle, GSize size); +``` + +### Oval Scale Modes +```c +GOvalScaleModeFitCircle // inscribed circle +GOvalScaleModeFillCircle // circumscribed circle +``` + +### Corner Masks for Rectangles +```c +GCornerNone +GCornersAll +GCornersTop +GCornersBottom +GCornersLeft +GCornersRight +GCornerTopLeft +GCornerTopRight +GCornerBottomLeft +GCornerBottomRight +``` + +## Paths (Vector Graphics) + +### Creating Paths +```c +static const GPathInfo PATH_INFO = { + .num_points = 3, + .points = (GPoint[]) { + {0, 0}, {10, 20}, {20, 0} + } +}; + +// Create path (do this once in window_load, not in update_proc!) +GPath *path = gpath_create(&PATH_INFO); +``` + +### Drawing Paths +```c +gpath_draw_filled(GContext *ctx, GPath *path); +gpath_draw_outline(GContext *ctx, GPath *path); +``` + +### Transforming Paths +```c +gpath_move_to(GPath *path, GPoint point); +gpath_rotate_to(GPath *path, int32_t angle); // angle in TRIG_MAX_ANGLE units +``` + +### Destroying Paths +```c +gpath_destroy(GPath *path); // Call in window_unload! +``` + +## Trigonometry (Fixed-Point) + +**IMPORTANT**: Pebble uses fixed-point math. No floating point! + +### Constants +```c +TRIG_MAX_ANGLE // Full circle = 65536 (0x10000) +TRIG_MAX_RATIO // Maximum sin/cos value = 65536 + +// Conversion macros +DEG_TO_TRIGANGLE(degrees) // Convert degrees to trig angle +``` + +### Functions +```c +int32_t sin_lookup(int32_t angle); // [-TRIG_MAX_RATIO, TRIG_MAX_RATIO] +int32_t cos_lookup(int32_t angle); +int32_t atan2_lookup(int16_t y, int16_t x); +``` + +### Usage Example +```c +int32_t angle = (hour * TRIG_MAX_ANGLE) / 12; +int16_t x = center.x + (sin_lookup(angle) * radius) / TRIG_MAX_RATIO; +int16_t y = center.y - (cos_lookup(angle) * radius) / TRIG_MAX_RATIO; +``` + +## Layers + +### Window Layer +```c +Layer *window_get_root_layer(Window *window); +GRect layer_get_bounds(Layer *layer); +GRect layer_get_unobstructed_bounds(Layer *layer); // excludes timeline peek +``` + +### Custom Layers +```c +Layer *layer_create(GRect frame); +void layer_destroy(Layer *layer); +void layer_set_update_proc(Layer *layer, LayerUpdateProc update_proc); +void layer_add_child(Layer *parent, Layer *child); +void layer_mark_dirty(Layer *layer); // Request redraw +void layer_set_hidden(Layer *layer, bool hidden); +void layer_set_frame(Layer *layer, GRect frame); +``` + +### Update Procedure +```c +static void canvas_update_proc(Layer *layer, GContext *ctx) { + GRect bounds = layer_get_bounds(layer); + // Draw here using bounds.size.w and bounds.size.h +} +``` + +### Text Layers +```c +TextLayer *text_layer_create(GRect frame); +void text_layer_destroy(TextLayer *layer); +void text_layer_set_text(TextLayer *layer, const char *text); +void text_layer_set_font(TextLayer *layer, GFont font); +void text_layer_set_text_color(TextLayer *layer, GColor color); +void text_layer_set_background_color(TextLayer *layer, GColor color); +void text_layer_set_text_alignment(TextLayer *layer, GTextAlignment alignment); +void text_layer_set_overflow_mode(TextLayer *layer, GTextOverflowMode mode); +Layer *text_layer_get_layer(TextLayer *layer); +``` + +### Text Alignment & Overflow +```c +GTextAlignmentLeft +GTextAlignmentCenter +GTextAlignmentRight + +GTextOverflowModeWordWrap +GTextOverflowModeTrailingEllipsis +GTextOverflowModeFill +``` + +### Drawing Text Directly +```c +graphics_draw_text(GContext *ctx, const char *text, GFont font, + GRect box, GTextOverflowMode overflow, + GTextAlignment alignment, GTextAttributes *attributes); +GSize graphics_text_layout_get_content_size(const char *text, GFont font, + GRect box, GTextOverflowMode overflow, + GTextAlignment alignment); +``` + +## Fonts + +### System Fonts +```c +GFont fonts_get_system_font(const char *font_key); + +// Common font keys +FONT_KEY_GOTHIC_14 +FONT_KEY_GOTHIC_14_BOLD +FONT_KEY_GOTHIC_18 +FONT_KEY_GOTHIC_18_BOLD +FONT_KEY_GOTHIC_24 +FONT_KEY_GOTHIC_24_BOLD +FONT_KEY_GOTHIC_28 +FONT_KEY_GOTHIC_28_BOLD +FONT_KEY_BITHAM_30_BLACK +FONT_KEY_BITHAM_42_BOLD +FONT_KEY_BITHAM_42_LIGHT +FONT_KEY_ROBOTO_CONDENSED_21 +FONT_KEY_LECO_20_BOLD_NUMBERS +FONT_KEY_LECO_26_BOLD_NUMBERS_AM_PM +FONT_KEY_LECO_32_BOLD_NUMBERS +FONT_KEY_LECO_36_BOLD_NUMBERS +FONT_KEY_LECO_38_BOLD_NUMBERS +FONT_KEY_LECO_42_NUMBERS +``` + +### Custom Fonts +```c +GFont fonts_load_custom_font(ResHandle handle); +void fonts_unload_custom_font(GFont font); +// Usage: fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_MY_FONT_42)) +``` + +## Windows + +### Creating Windows +```c +Window *window_create(void); +void window_destroy(Window *window); +void window_set_background_color(Window *window, GColor color); +void window_set_window_handlers(Window *window, WindowHandlers handlers); +void window_stack_push(Window *window, bool animated); +``` + +### Window Handlers +```c +window_set_window_handlers(window, (WindowHandlers) { + .load = main_window_load, + .unload = main_window_unload +}); +``` + +## Time + +### Getting Current Time +```c +time_t temp = time(NULL); +struct tm *tick_time = localtime(&temp); + +tick_time->tm_hour // 0-23 +tick_time->tm_min // 0-59 +tick_time->tm_sec // 0-59 +tick_time->tm_mday // 1-31 +tick_time->tm_mon // 0-11 +tick_time->tm_year // Years since 1900 +tick_time->tm_wday // 0-6 (Sunday = 0) +``` + +### Time Formatting +```c +static char buffer[8]; +strftime(buffer, sizeof(buffer), "%H:%M", tick_time); // 24-hour +strftime(buffer, sizeof(buffer), "%I:%M", tick_time); // 12-hour + +// Check user preference +clock_is_24h_style() // returns bool +``` + +### Tick Timer Service +```c +void tick_timer_service_subscribe(TimeUnits units, TickHandler handler); +void tick_timer_service_unsubscribe(void); + +// TimeUnits — ALWAYS USE MINUTE_UNIT for watchfaces (battery efficiency!) +MINUTE_UNIT // ← DEFAULT for watchfaces +HOUR_UNIT +DAY_UNIT +SECOND_UNIT // ← AVOID: drains battery rapidly + +// Handler +static void tick_handler(struct tm *tick_time, TimeUnits units_changed) { + update_time(); +} +``` + +**WARNING**: Using `SECOND_UNIT` causes a redraw every second and significantly reduces battery life. Only use if the user explicitly requests seconds display. + +## App Timers (for Animation) + +```c +AppTimer *app_timer_register(uint32_t timeout_ms, AppTimerCallback callback, void *data); +void app_timer_cancel(AppTimer *timer); +bool app_timer_reschedule(AppTimer *timer, uint32_t new_timeout_ms); + +static void timer_callback(void *data) { + layer_mark_dirty(s_canvas_layer); + s_timer = app_timer_register(50, timer_callback, NULL); // 50ms = 20 FPS +} +``` + +## Battery Service + +```c +BatteryChargeState battery_state_service_peek(void); +void battery_state_service_subscribe(BatteryStateHandler handler); +void battery_state_service_unsubscribe(void); + +typedef struct { + uint8_t charge_percent; // 0-100 + bool is_charging; + bool is_plugged; +} BatteryChargeState; +``` + +## Connection Service + +```c +bool connection_service_peek_pebble_app_connection(void); +void connection_service_subscribe(ConnectionHandlers handlers); +void connection_service_unsubscribe(void); + +connection_service_subscribe((ConnectionHandlers) { + .pebble_app_connection_handler = bluetooth_callback +}); +// Callback: void(bool connected) +``` + +## Vibration + +```c +vibes_short_pulse(void); +vibes_long_pulse(void); +vibes_double_pulse(void); +``` + +## AppMessage (Phone ↔ Watch Communication) + +Used for weather, web data, and configuration. Requires PebbleKit JS on the phone side. + +### Setup (C side) +```c +// Register callbacks BEFORE opening (in init()) +app_message_register_inbox_received(inbox_received_callback); +app_message_register_inbox_dropped(inbox_dropped_callback); +app_message_register_outbox_failed(outbox_failed_callback); +app_message_register_outbox_sent(outbox_sent_callback); + +// Open with buffer sizes +app_message_open(128, 128); // inbox_size, outbox_size +``` + +### Receiving Messages (C side) +```c +static void inbox_received_callback(DictionaryIterator *iterator, void *context) { + // Look up tuples by MESSAGE_KEY_* (auto-generated from package.json messageKeys) + Tuple *temp_tuple = dict_find(iterator, MESSAGE_KEY_TEMPERATURE); + Tuple *cond_tuple = dict_find(iterator, MESSAGE_KEY_CONDITIONS); + + if (temp_tuple) { + int temperature = (int)temp_tuple->value->int32; + } + if (cond_tuple) { + char *conditions = cond_tuple->value->cstring; + } +} +``` + +### Sending Messages (C side) +```c +DictionaryIterator *iter; +app_message_outbox_begin(&iter); +dict_write_uint8(iter, MESSAGE_KEY_REQUEST_WEATHER, 1); +app_message_outbox_send(); +``` + +### PebbleKit JS (Phone side — src/pkjs/index.js) +```javascript +// Send data to watch +Pebble.sendAppMessage( + { 'TEMPERATURE': 72, 'CONDITIONS': 'Clear' }, + function(e) { console.log('Sent!'); }, + function(e) { console.log('Failed!'); } +); + +// Receive from watch +Pebble.addEventListener('appmessage', function(e) { + if (e.payload['REQUEST_WEATHER']) { + // Fetch weather and send back... + } +}); + +// JS runtime ready +Pebble.addEventListener('ready', function(e) { + // Safe to start fetching data +}); + +// Available APIs in PebbleKit JS: +// - XMLHttpRequest (HTTP requests) +// - navigator.geolocation (GPS) +// - localStorage (persistent key-value storage) +// - Pebble.getActiveWatchInfo() (watch platform, model, firmware) +``` + +### package.json Requirements for AppMessage +```json +{ + "pebble": { + "enableMultiJS": true, + "capabilities": ["location"], + "messageKeys": ["TEMPERATURE", "CONDITIONS", "REQUEST_WEATHER"] + } +} +``` + +Message keys become `MESSAGE_KEY_*` constants in C code automatically. + +## Unobstructed Area (Quick View) + +Handle timeline peek that covers part of the screen: +```c +UnobstructedAreaHandlers handlers = { + .will_change = prv_unobstructed_will_change, + .change = prv_unobstructed_change, + .did_change = prv_unobstructed_did_change +}; +unobstructed_area_service_subscribe(handlers, NULL); + +// Get visible bounds (excluding peek area) +GRect visible = layer_get_unobstructed_bounds(window_layer); +``` + +## Random Numbers + +```c +#include +srand(time(NULL)); // Seed once in init() +int value = rand() % range; +``` + +## Logging + +```c +APP_LOG(APP_LOG_LEVEL_DEBUG, "Debug: %d", value); +APP_LOG(APP_LOG_LEVEL_INFO, "Info message"); +APP_LOG(APP_LOG_LEVEL_WARNING, "Warning!"); +APP_LOG(APP_LOG_LEVEL_ERROR, "Error!"); +``` + +## Platform Detection + +```c +#ifdef PBL_COLOR + // Color display (basalt, chalk, emery, gabbro, flint) +#else + // Black and white (aplite, diorite) +#endif + +#ifdef PBL_ROUND + // Round display (chalk, gabbro) +#else + // Rectangular display (aplite, basalt, diorite, emery, flint) +#endif + +// Round/rect conditional macro +PBL_IF_ROUND_ELSE(round_value, rect_value) +PBL_IF_COLOR_ELSE(color_value, bw_value) + +// Platform-specific +#ifdef PBL_PLATFORM_EMERY // Pebble Time 2 (200x228) +#endif +#ifdef PBL_PLATFORM_BASALT // Pebble Time (144x168) +#endif +#ifdef PBL_PLATFORM_CHALK // Pebble Time Round (180x180) +#endif +#ifdef PBL_PLATFORM_APLITE // Pebble Classic (144x168, B&W) +#endif +#ifdef PBL_PLATFORM_DIORITE // Pebble 2 (144x168, B&W) +#endif +``` + +## Application Lifecycle + +```c +static void init(void) { + srand(time(NULL)); + s_main_window = window_create(); + window_set_window_handlers(s_main_window, (WindowHandlers) { + .load = main_window_load, + .unload = main_window_unload + }); + window_stack_push(s_main_window, true); + tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); + battery_state_service_subscribe(battery_callback); +} + +static void deinit(void) { + tick_timer_service_unsubscribe(); + battery_state_service_unsubscribe(); + window_destroy(s_main_window); +} + +int main(void) { + init(); + app_event_loop(); + deinit(); + return 0; +} +``` + +## Watch Info + +```c +WatchInfoModel watch_info_get_model(void); +// Returns: PEBBLE_ORIGINAL, PEBBLE_TIME, PEBBLE_TIME_ROUND_14, +// PEBBLE_2_HR, COREDEVICES_PT2, COREDEVICES_PR2, COREDEVICES_P2D + +WatchInfoVersion watch_info_get_firmware_version(void); +WatchInfoColor watch_info_get_color(void); +``` diff --git a/cloudpebble-agent/skills/pebble-watchface/reference/watchapp-guide.md b/cloudpebble-agent/skills/pebble-watchface/reference/watchapp-guide.md new file mode 100644 index 0000000..8ed7b1b --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/reference/watchapp-guide.md @@ -0,0 +1,209 @@ +# Watchapp Guide (C) + +Watchapps differ from watchfaces in one config flag plus interaction model. Build, wscript, emulator flow all identical. + +> **Inside CloudPebble:** the config flag is `set_app_settings(app_is_watchface=false)`, +> not a `package.json` edit. Everything else here applies as written. + +## Config Difference + +package.json: `"watchapp": { "watchface": false }`. That's it. Same `main()`/`init()`/`deinit()`/`app_event_loop()` skeleton. + +| | Watchface | Watchapp | +|---|---|---| +| Launch | System (default face) | Launcher, Quick Launch, phone, wakeup, timeline action, worker | +| Exit | System (user presses button to leave) | BACK pops windows; app exits when stack empties; long-press BACK force-quits (not overridable) | +| Buttons | **None** — use accel taps | Full ClickHandler API | +| Status bar | Never | Optional StatusBarLayer | + +`launch_reason()` → `APP_LAUNCH_USER/PHONE/WAKEUP/WORKER/QUICK_LAUNCH/TIMELINE_ACTION/...` +`exit_reason_set(APP_EXIT_ACTION_PERFORMED_SUCCESSFULLY)` before exit → returns user to watchface instead of launcher (one-shot action apps). + +## Button Input + +```c +static void select_click_handler(ClickRecognizerRef recognizer, void *context) { } + +static void click_config_provider(void *context) { + window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler); + window_single_repeating_click_subscribe(BUTTON_ID_UP, 100, up_repeat_handler); // fires while held + window_long_click_subscribe(BUTTON_ID_DOWN, 500, long_down_handler, NULL); + window_multi_click_subscribe(BUTTON_ID_SELECT, 2, 2, 300, true, double_click_handler); + window_raw_click_subscribe(BUTTON_ID_UP, down_handler, up_handler, NULL); // raw down/up for games +} + +// in window setup: +window_set_click_config_provider(window, click_config_provider); +``` + +- Buttons: `BUTTON_ID_BACK/UP/SELECT/DOWN`. +- BACK: single-click overridable; no repeating/long handlers; long-press always terminates. +- `click_recognizer_get_button_id(recognizer)`, `click_number_of_clicks_counted(recognizer)` inside handlers. + +## Window Stack (multi-screen apps) + +```c +window_stack_push(window, true); // animated slide +window_stack_pop(true); +window_stack_pop_all(true); +window_stack_get_top_window(); +``` + +Each screen = one Window with own load/unload + click config provider. Push detail window on select; BACK pops automatically; app exits when stack empties. + +## UI Components + +### MenuLayer (scrolling list) + +```c +static uint16_t get_num_rows(MenuLayer *ml, uint16_t section, void *ctx) { return 5; } +static void draw_row(GContext *ctx, const Layer *cell, MenuIndex *idx, void *data) { + menu_cell_basic_draw(ctx, cell, "Title", "Subtitle", NULL); // NULL = no icon +} +static void select_cb(MenuLayer *ml, MenuIndex *idx, void *ctx) { /* push detail window */ } + +// window load: +s_menu = menu_layer_create(layer_get_bounds(window_get_root_layer(window))); +menu_layer_set_callbacks(s_menu, NULL, (MenuLayerCallbacks) { + .get_num_rows = get_num_rows, .draw_row = draw_row, .select_click = select_cb, +}); +menu_layer_set_click_config_onto_window(s_menu, window); // wires UP/DOWN/SELECT +layer_add_child(window_get_root_layer(window), menu_layer_get_layer(s_menu)); +// unload: menu_layer_destroy(s_menu); +``` + +### Menu → Detail data passing (standard idiom) + +One reusable detail Window + a static selected-index; select stores the index and pushes; detail's load/update reads it: + +```c +static int s_selected; +static Window *s_detail_window; + +static void select_cb(MenuLayer *ml, MenuIndex *idx, void *ctx) { + s_selected = idx->row; + window_stack_push(s_detail_window, true); // create once in init, reuse +} +// detail window .appear handler (fires every push): +static void detail_appear(Window *w) { + text_layer_set_text(s_name_layer, ITEMS[s_selected].name); + layer_mark_dirty(s_drawing_layer); // update proc reads s_selected +} +``` + +Use `.appear` (not `.load`) for per-selection refresh — `.load` only fires on first push of a reused window. + +### SimpleMenuLayer (static lists — less boilerplate) + +`simple_menu_layer_create(frame, window, sections, num_sections, ctx)` with static (long-lived) `SimpleMenuSection{title, items, num_items}` / `SimpleMenuItem{title, subtitle, icon, callback}` arrays. + +### ActionBarLayer (right-edge icon bar) + +```c +s_action_bar = action_bar_layer_create(); +action_bar_layer_set_icon(s_action_bar, BUTTON_ID_UP, s_icon_up); +action_bar_layer_set_click_config_provider(s_action_bar, click_config_provider); +action_bar_layer_add_to_window(s_action_bar, window); +// Content layers: width = bounds.size.w - ACTION_BAR_WIDTH (30 std, 34 emery/gabbro, 40 chalk) +``` + +### StatusBarLayer + +```c +s_status_bar = status_bar_layer_create(); // STATUS_BAR_LAYER_HEIGHT (16 on rect) +status_bar_layer_set_colors(s_status_bar, GColorBlack, GColorWhite); +layer_add_child(window_layer, status_bar_layer_get_layer(s_status_bar)); +// Content below: GRect(0, STATUS_BAR_LAYER_HEIGHT, w, h - STATUS_BAR_LAYER_HEIGHT) +``` + +### ScrollLayer + +`scroll_layer_create(frame)` + `scroll_layer_set_content_size(sl, GSize(w, content_h))` (manual!) + `scroll_layer_add_child` + `scroll_layer_set_click_config_onto_window` (UP/DOWN auto-scroll). + +### ActionMenu (hierarchical action picker) + +`action_menu_level_create(n)` → `action_menu_level_add_action(level, "Label", cb, data)` → `action_menu_open(&(ActionMenuConfig){.root_level=..., .colors={...}})`. Destroy hierarchy with `action_menu_hierarchy_destroy`. + +### Dialogs + +No Dialog API. Pattern: Window + TextLayer (+ BitmapLayer icon), dismissed via BACK or AppTimer. `NumberWindow` is the only prebuilt input window. + +## Game Loop Pattern + +```c +#define FRAME_MS 33 // ~30fps + +static void game_tick(void *data) { + update_game_state(); // physics, read held-button flags + layer_mark_dirty(s_game_layer); // schedules redraw of LayerUpdateProc + s_timer = app_timer_register(FRAME_MS, game_tick, NULL); +} + +// drawing: +static void game_draw(Layer *layer, GContext *ctx) { /* graphics_* calls */ } +layer_set_update_proc(s_game_layer, game_draw); + +// input: raw clicks set/clear flags read by game_tick +static void up_down(ClickRecognizerRef r, void *ctx) { s_up_held = true; } +static void up_up(ClickRecognizerRef r, void *ctx) { s_up_held = false; } +window_raw_click_subscribe(BUTTON_ID_UP, up_down, up_up, NULL); + +// start in window appear; STOP in window disappear/unload: +app_timer_cancel(s_timer); +``` + +Tweens (non-game): `PropertyAnimation` via `property_animation_create_layer_frame(layer, &from, &to)` + `animation_schedule()`; custom via `AnimationImplementation.update` receiving 0..65535 progress. + +Drawing note: the SDK has **no ellipse primitive**. Rings: `graphics_fill_circle` then punch out with a background-color fill_circle (or `graphics_draw_circle` for 1px outline). Tilted/elliptical shapes (e.g. Saturn's ring): GPath polygon, or stacked 1px horizontal lines with per-row width. + +## App-Only Capabilities + +### Persistent storage + +```c +persist_write_int(KEY_SCORE, score); +int score = persist_read_int(KEY_SCORE); // 0 if unset +persist_exists(KEY); persist_write_string/data/bool(...); persist_delete(KEY); +``` + +256 B max per value (`PERSIST_DATA_MAX_LENGTH`), ~4 KB total per app. Save game state in window disappear. (Also works in watchfaces.) + +### Wakeup (scheduled app launches) + +```c +WakeupId id = wakeup_schedule(timestamp, cookie, true); +wakeup_service_subscribe(wakeup_handler); // if app running when it fires +// at launch: if (launch_reason() == APP_LAUNCH_WAKEUP) wakeup_get_launch_event(&id, &cookie); +``` + +Max 8 scheduled; none within 1 min of another; store WakeupIds in persist. + +### App Glances (launcher subtitle) + +```c +app_glance_reload(glance_reload_cb, NULL); // in window unload / before exit +// in cb: app_glance_add_slice(session, (AppGlanceSlice){ +// .layout = {.icon = ..., .subtitle_template_string = "..."}, +// .expiration_time = APP_GLANCE_SLICE_NO_EXPIRATION}); +``` + +Not on aplite. + +### Background workers + +`worker_src/c/worker.c` with `#include `, own `main()` + `worker_event_loop()`. **10.5 kB limit**, no UI/AppMessage/resources. `app_worker_launch()/kill()`, AppWorkerMessage for live comms, shared persist storage. + +### Web APIs (AppMessage + pkjs) + +Same as watchfaces — see SKILL.md weather section. Apps commonly use bigger buffers: `app_message_open(app_message_inbox_size_maximum(), app_message_outbox_size_maximum())` (minimums: inbox 124 B, outbox 636 B). Register callbacks before open. + +## Memory Limits (code + heap) + +| Platform | Limit | +|---|---| +| aplite | 24 KB | +| basalt / chalk / diorite / flint | 64 KB | +| emery / gabbro | 128 KB | +| background worker | 10.5 KB | + +Use `heap_bytes_free()` when debugging. Per-platform code: `PBL_IF_ROUND_ELSE()`, `PBL_IF_COLOR_ELSE()`, `#ifdef PBL_PLATFORM_EMERY`. Always use `ACTION_BAR_WIDTH`/`STATUS_BAR_LAYER_HEIGHT` macros, never hardcode. diff --git a/cloudpebble-agent/skills/pebble-watchface/templates/alloy-manifest.json b/cloudpebble-agent/skills/pebble-watchface/templates/alloy-manifest.json new file mode 100644 index 0000000..50d28cc --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/templates/alloy-manifest.json @@ -0,0 +1,8 @@ +{ + "include": [ + "$(MODDABLE)/examples/manifest_mod.json" + ], + "modules": { + "*": "./main.js" + } +} diff --git a/cloudpebble-agent/skills/pebble-watchface/templates/alloy-watchface.js b/cloudpebble-agent/skills/pebble-watchface/templates/alloy-watchface.js new file mode 100644 index 0000000..ac7d730 --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/templates/alloy-watchface.js @@ -0,0 +1,44 @@ +// Minimal Alloy watchface (src/embeddedjs/main.js) +// From coredevices/alloy-watchface-tutorial part1. +import Poco from "commodetto/Poco"; + +const render = new Poco(screen); + +// Fonts (system fonts by name + size) +const timeFont = new render.Font("Bitham-Bold", 42); +const dateFont = new render.Font("Gothic-Bold", 24); + +// Colors (reuse objects — don't recreate per frame) +const black = render.makeColor(0, 0, 0); +const white = render.makeColor(255, 255, 255); + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function draw(event) { + const now = event.date; + + render.begin(); + render.fillRectangle(black, 0, 0, render.width, render.height); + + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const timeStr = `${hours}:${minutes}`; + + let width = render.getTextWidth(timeStr, timeFont); + render.drawText(timeStr, timeFont, white, + (render.width - width) / 2, + (render.height / 2) - timeFont.height + 5); + + const dateStr = `${DAYS[now.getDay()]} ${MONTHS[now.getMonth()]} ${String(now.getDate()).padStart(2, "0")}`; + width = render.getTextWidth(dateStr, dateFont); + render.drawText(dateStr, dateFont, white, + (render.width - width) / 2, + (render.height / 2) + 10); + + render.end(); +} + +// Fires immediately on registration = initial draw. ALWAYS minutechange, not secondchange. +watch.addEventListener("minutechange", draw); diff --git a/cloudpebble-agent/skills/pebble-watchface/templates/animated-watchface.c b/cloudpebble-agent/skills/pebble-watchface/templates/animated-watchface.c new file mode 100644 index 0000000..795b671 --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/templates/animated-watchface.c @@ -0,0 +1,432 @@ +/** + * Animated Pebble Watchface Template + * + * This template provides a foundation for creating animated watchfaces + * with multiple moving elements, efficient memory management, and + * battery-aware animation throttling. + * + * Customize the animated elements, drawing functions, and update logic + * to create your unique watchface design. + */ + +#include + +// ============================================================================ +// CONFIGURATION - Customize these values +// ============================================================================ + +#define WATCHFACE_NAME "My Animated Watch" + +// Animation settings +#define ANIMATION_INTERVAL 50 // Normal: 50ms = 20 FPS +#define ANIMATION_INTERVAL_LOW_POWER 100 // Low battery: 100ms = 10 FPS +#define LOW_BATTERY_THRESHOLD 20 // Throttle below 20% + +// Element counts - adjust based on your design +#define MAX_PARTICLES 8 +#define MAX_MOVING_OBJECTS 4 + +// ============================================================================ +// DATA STRUCTURES - Define your animated elements +// ============================================================================ + +typedef struct { + GPoint pos; + int direction; // 1 or -1 + int speed; + bool active; +} MovingObject; + +typedef struct { + GPoint pos; + int size; + int speed; + bool active; +} Particle; + +// ============================================================================ +// GLOBAL STATE +// ============================================================================ + +// UI Elements +static Window *s_main_window; +static Layer *s_canvas_layer; +static TextLayer *s_time_layer; +static TextLayer *s_date_layer; +static Layer *s_battery_layer; +static AppTimer *s_animation_timer; + +// Battery state +static int s_battery_level = 100; +static bool s_is_charging = false; + +// Animated elements +static MovingObject s_objects[MAX_MOVING_OBJECTS]; +static Particle s_particles[MAX_PARTICLES]; + +// Animation state +static int32_t s_animation_phase = 0; + +// Pre-allocated paths (for complex shapes) +static GPath *s_shape_path = NULL; +static GPoint s_shape_points[4]; +static GPathInfo s_shape_info = { + .num_points = 4, + .points = s_shape_points +}; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +static int random_in_range(int min, int max) { + if (max <= min) return min; + int range = max - min + 1; + return min + (rand() % range); +} + +// ============================================================================ +// INITIALIZATION FUNCTIONS +// ============================================================================ + +// Screen dimensions — set in window_load from layer_get_bounds() +static int s_screen_w = 200; +static int s_screen_h = 228; + +static void init_moving_object(MovingObject *obj) { + obj->pos.y = random_in_range(30, s_screen_h - 40); + obj->direction = (random_in_range(0, 1) * 2) - 1; + obj->speed = random_in_range(1, 3); + obj->pos.x = (obj->direction == 1) ? -10 : s_screen_w + 10; + obj->active = true; +} + +static void init_particle(Particle *p) { + p->pos.x = random_in_range(10, s_screen_w - 10); + p->pos.y = s_screen_h; // Start at bottom + p->size = random_in_range(1, 3); + p->speed = random_in_range(1, 3); + p->active = true; +} + +// ============================================================================ +// DRAWING FUNCTIONS - Customize your visuals here +// ============================================================================ + +static void draw_moving_object(GContext *ctx, const MovingObject *obj) { + if (!obj || !obj->active) return; + + graphics_context_set_fill_color(ctx, GColorWhite); + + // Example: Draw a simple circle + graphics_fill_circle(ctx, obj->pos, 5); + + // Example: Draw a directional tail + GPoint tail_end = { + obj->pos.x - (obj->direction * 10), + obj->pos.y + }; + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_draw_line(ctx, obj->pos, tail_end); +} + +static void draw_particle(GContext *ctx, const Particle *p) { + if (!p || !p->active) return; + + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_draw_circle(ctx, p->pos, p->size); +} + +static void draw_background_element(GContext *ctx, int32_t phase) { + // Example: Oscillating background element + int16_t offset = (sin_lookup(phase) * 10) / TRIG_MAX_RATIO; + + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_context_set_stroke_width(ctx, 2); + + GPoint start = {20, 160}; + GPoint end = {20 + offset, 140}; + graphics_draw_line(ctx, start, end); + + // Add more background elements as needed +} + +// ============================================================================ +// UPDATE FUNCTIONS +// ============================================================================ + +static void update_moving_objects(void) { + for (int i = 0; i < MAX_MOVING_OBJECTS; i++) { + if (!s_objects[i].active) continue; + + s_objects[i].pos.x += s_objects[i].direction * s_objects[i].speed; + + // Reset when off screen + if ((s_objects[i].direction == 1 && s_objects[i].pos.x > s_screen_w + 10) || + (s_objects[i].direction == -1 && s_objects[i].pos.x < -10)) { + init_moving_object(&s_objects[i]); + } + } +} + +static void update_particles(void) { + for (int i = 0; i < MAX_PARTICLES; i++) { + if (s_particles[i].active) { + s_particles[i].pos.y -= s_particles[i].speed; + + // Slight horizontal wobble + if (random_in_range(0, 2) == 0) { + s_particles[i].pos.x += random_in_range(-1, 1); + } + + // Deactivate when off screen + if (s_particles[i].pos.y < 0) { + s_particles[i].active = false; + } + } else { + // Random chance to spawn + if (random_in_range(0, 100) < 2) { + init_particle(&s_particles[i]); + } + } + } +} + +static void animation_update(void) { + // Update animation phase with overflow protection + s_animation_phase = (s_animation_phase + 200) % TRIG_MAX_ANGLE; + + // Update all animated elements + update_moving_objects(); + update_particles(); + + // Request redraw + if (s_canvas_layer) { + layer_mark_dirty(s_canvas_layer); + } +} + +// ============================================================================ +// LAYER UPDATE PROCEDURES +// ============================================================================ + +static void canvas_update_proc(Layer *layer, GContext *ctx) { + GRect bounds = layer_get_bounds(layer); + + // Clear background + graphics_context_set_fill_color(ctx, GColorBlack); + graphics_fill_rect(ctx, bounds, 0, GCornerNone); + + // Draw background elements + draw_background_element(ctx, s_animation_phase); + + // Draw particles + for (int i = 0; i < MAX_PARTICLES; i++) { + draw_particle(ctx, &s_particles[i]); + } + + // Draw moving objects + for (int i = 0; i < MAX_MOVING_OBJECTS; i++) { + draw_moving_object(ctx, &s_objects[i]); + } +} + +static void battery_update_proc(Layer *layer, GContext *ctx) { + BatteryChargeState state = battery_state_service_peek(); + + const int WIDTH = 20; + const int HEIGHT = 8; + GRect outline = {{0, 0}, {WIDTH, HEIGHT}}; + + // Outline + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_draw_rect(ctx, outline); + + // Fill based on level + int fill_width = (state.charge_percent * WIDTH) / 100; + GRect fill = {{0, 0}, {fill_width, HEIGHT}}; + graphics_context_set_fill_color(ctx, GColorWhite); + graphics_fill_rect(ctx, fill, 0, GCornerNone); +} + +// ============================================================================ +// TIME HANDLING +// ============================================================================ + +static void update_time(void) { + time_t temp = time(NULL); + struct tm *tick_time = localtime(&temp); + + if (!tick_time || !s_time_layer || !s_date_layer) return; + + static char time_buffer[8]; + strftime(time_buffer, sizeof(time_buffer), "%I:%M", tick_time); + text_layer_set_text(s_time_layer, time_buffer); + + static char date_buffer[24]; + strftime(date_buffer, sizeof(date_buffer), "%a, %b %d", tick_time); + text_layer_set_text(s_date_layer, date_buffer); +} + +static void tick_handler(struct tm *tick_time, TimeUnits units_changed) { + update_time(); +} + +// ============================================================================ +// TIMER HANDLING +// ============================================================================ + +static void animation_timer_callback(void *data) { + animation_update(); + + // Schedule next frame (battery-aware) + uint32_t interval = (s_battery_level <= LOW_BATTERY_THRESHOLD && !s_is_charging) + ? ANIMATION_INTERVAL_LOW_POWER + : ANIMATION_INTERVAL; + + s_animation_timer = app_timer_register(interval, animation_timer_callback, NULL); +} + +// ============================================================================ +// BATTERY HANDLING +// ============================================================================ + +static void battery_callback(BatteryChargeState state) { + s_battery_level = state.charge_percent; + s_is_charging = state.is_charging; + + if (s_battery_layer) { + layer_mark_dirty(s_battery_layer); + } +} + +// ============================================================================ +// WINDOW HANDLERS +// ============================================================================ + +static void main_window_load(Window *window) { + Layer *window_layer = window_get_root_layer(window); + GRect bounds = layer_get_bounds(window_layer); + + // Store screen dimensions for animation calculations + s_screen_w = bounds.size.w; + s_screen_h = bounds.size.h; + + // Canvas layer (full screen for animations) + s_canvas_layer = layer_create(bounds); + layer_set_update_proc(s_canvas_layer, canvas_update_proc); + layer_add_child(window_layer, s_canvas_layer); + + // Time layer + GRect time_frame = {{0, 50}, {bounds.size.w, 34}}; + s_time_layer = text_layer_create(time_frame); + text_layer_set_text_color(s_time_layer, GColorWhite); + text_layer_set_background_color(s_time_layer, GColorClear); + text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD)); + text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter); + layer_add_child(window_layer, text_layer_get_layer(s_time_layer)); + + // Date layer + GRect date_frame = {{0, 84}, {bounds.size.w, 20}}; + s_date_layer = text_layer_create(date_frame); + text_layer_set_text_color(s_date_layer, GColorWhite); + text_layer_set_background_color(s_date_layer, GColorClear); + text_layer_set_font(s_date_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18)); + text_layer_set_text_alignment(s_date_layer, GTextAlignmentCenter); + layer_add_child(window_layer, text_layer_get_layer(s_date_layer)); + + // Battery layer + GRect battery_frame = {{bounds.size.w - 25, 5}, {20, 8}}; + s_battery_layer = layer_create(battery_frame); + layer_set_update_proc(s_battery_layer, battery_update_proc); + layer_add_child(window_layer, s_battery_layer); + + // Initialize animated elements + for (int i = 0; i < MAX_MOVING_OBJECTS; i++) { + init_moving_object(&s_objects[i]); + } + for (int i = 0; i < MAX_PARTICLES; i++) { + s_particles[i].active = false; + } + + // Create pre-allocated paths + s_shape_path = gpath_create(&s_shape_info); + + // Start animation timer + s_animation_timer = app_timer_register(ANIMATION_INTERVAL, animation_timer_callback, NULL); + + // Initial time update + update_time(); +} + +static void main_window_unload(Window *window) { + // Cancel animation timer + if (s_animation_timer) { + app_timer_cancel(s_animation_timer); + s_animation_timer = NULL; + } + + // Destroy paths + if (s_shape_path) { + gpath_destroy(s_shape_path); + s_shape_path = NULL; + } + + // Destroy layers + if (s_canvas_layer) { + layer_destroy(s_canvas_layer); + s_canvas_layer = NULL; + } + if (s_time_layer) { + text_layer_destroy(s_time_layer); + s_time_layer = NULL; + } + if (s_date_layer) { + text_layer_destroy(s_date_layer); + s_date_layer = NULL; + } + if (s_battery_layer) { + layer_destroy(s_battery_layer); + s_battery_layer = NULL; + } +} + +// ============================================================================ +// APPLICATION LIFECYCLE +// ============================================================================ + +static void init(void) { + srand(time(NULL)); + + s_main_window = window_create(); + window_set_window_handlers(s_main_window, (WindowHandlers) { + .load = main_window_load, + .unload = main_window_unload + }); + window_stack_push(s_main_window, true); + + tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); + battery_state_service_subscribe(battery_callback); + + // Get initial battery state + BatteryChargeState state = battery_state_service_peek(); + s_battery_level = state.charge_percent; + s_is_charging = state.is_charging; +} + +static void deinit(void) { + tick_timer_service_unsubscribe(); + battery_state_service_unsubscribe(); + + if (s_main_window) { + window_destroy(s_main_window); + s_main_window = NULL; + } +} + +int main(void) { + init(); + app_event_loop(); + deinit(); + return 0; +} diff --git a/cloudpebble-agent/skills/pebble-watchface/templates/pkjs-weather.js b/cloudpebble-agent/skills/pebble-watchface/templates/pkjs-weather.js new file mode 100644 index 0000000..a764dfd --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/templates/pkjs-weather.js @@ -0,0 +1,93 @@ +/** + * PebbleKit JS Weather Template + * + * Fetches weather data from Open-Meteo API (free, no API key needed) + * and sends temperature + conditions to the watch via AppMessage. + * + * Requires package.json to have: + * "capabilities": ["location"], + * "messageKeys": ["TEMPERATURE", "CONDITIONS", "REQUEST_WEATHER"], + * "enableMultiJS": true + * + * Place this file at: src/pkjs/index.js + */ + +var xhrRequest = function (url, type, callback) { + var xhr = new XMLHttpRequest(); + xhr.onload = function () { + callback(this.responseText); + }; + xhr.open(type, url); + xhr.send(); +}; + +/** + * Convert WMO weather codes to short human-readable strings. + * See: https://open-meteo.com/en/docs (WMO Weather interpretation codes) + */ +function weatherCodeToCondition(code) { + if (code === 0) return 'Clear'; + if (code <= 3) return 'Cloudy'; + if (code <= 48) return 'Fog'; + if (code <= 55) return 'Drizzle'; + if (code <= 57) return 'Fz. Drizzle'; + if (code <= 65) return 'Rain'; + if (code <= 67) return 'Fz. Rain'; + if (code <= 75) return 'Snow'; + if (code <= 77) return 'Snow Grains'; + if (code <= 82) return 'Showers'; + if (code <= 86) return 'Snow Shwrs'; + if (code === 95) return 'T-Storm'; + if (code <= 99) return 'T-Storm'; + return 'Unknown'; +} + +function locationSuccess(pos) { + // Open-Meteo: free weather API, no key required + var url = 'https://api.open-meteo.com/v1/forecast?' + + 'latitude=' + pos.coords.latitude + + '&longitude=' + pos.coords.longitude + + '¤t=temperature_2m,weather_code'; + + xhrRequest(url, 'GET', function(responseText) { + var json = JSON.parse(responseText); + var temperature = Math.round(json.current.temperature_2m); + var conditions = weatherCodeToCondition(json.current.weather_code); + + var dictionary = { + 'TEMPERATURE': temperature, + 'CONDITIONS': conditions + }; + + Pebble.sendAppMessage(dictionary, + function(e) { console.log('Weather info sent to Pebble successfully!'); }, + function(e) { console.log('Error sending weather info to Pebble!'); } + ); + }); +} + +function locationError(err) { + console.log('Error requesting location!'); +} + +function getWeather() { + navigator.geolocation.getCurrentPosition( + locationSuccess, + locationError, + { timeout: 15000, maximumAge: 60000 } + ); +} + +// Fetch weather when JS runtime is ready +Pebble.addEventListener('ready', function(e) { + console.log('PebbleKit JS ready!'); + getWeather(); +}); + +// Handle weather refresh requests from the watch +Pebble.addEventListener('appmessage', function(e) { + console.log('AppMessage received!'); + if (e.payload['REQUEST_WEATHER']) { + getWeather(); + } +}); diff --git a/cloudpebble-agent/skills/pebble-watchface/templates/static-watchface.c b/cloudpebble-agent/skills/pebble-watchface/templates/static-watchface.c new file mode 100644 index 0000000..81a3985 --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/templates/static-watchface.c @@ -0,0 +1,355 @@ +/** + * Static/Analog Pebble Watchface Template + * + * This template provides a foundation for creating static watchfaces + * including analog clock designs. Optimized for battery efficiency + * with minute-based updates. + * + * Customize the drawing functions to create your unique design. + */ + +#include + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +#define WATCHFACE_NAME "My Static Watch" + +// Clock configuration +#define CLOCK_RADIUS 60 +#define HOUR_HAND_LENGTH 35 +#define MINUTE_HAND_LENGTH 50 +#define SECOND_HAND_LENGTH 55 +#define SHOW_SECOND_HAND false // Set to true for second hand (uses more battery) + +// ============================================================================ +// GLOBAL STATE +// ============================================================================ + +static Window *s_main_window; +static Layer *s_canvas_layer; +static TextLayer *s_date_layer; +static Layer *s_battery_layer; + +static int s_battery_level = 100; + +// Clock center (calculated in window_load) +static GPoint s_center; + +// Pre-allocated paths for clock hands +static GPath *s_hour_hand_path = NULL; +static GPath *s_minute_hand_path = NULL; + +static GPoint s_hour_hand_points[4]; +static GPoint s_minute_hand_points[4]; + +static GPathInfo s_hour_hand_info = { + .num_points = 4, + .points = s_hour_hand_points +}; + +static GPathInfo s_minute_hand_info = { + .num_points = 4, + .points = s_minute_hand_points +}; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +static void calculate_hand_points(GPoint *points, int length, int width) { + // Diamond-shaped hand pointing up (will be rotated) + points[0] = GPoint(0, -length); // Tip + points[1] = GPoint(width, -length/3); // Right side + points[2] = GPoint(0, length/5); // Bottom + points[3] = GPoint(-width, -length/3); // Left side +} + +// ============================================================================ +// DRAWING FUNCTIONS +// ============================================================================ + +static void draw_clock_face(GContext *ctx) { + // Outer circle + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_context_set_stroke_width(ctx, 2); + graphics_draw_circle(ctx, s_center, CLOCK_RADIUS); + + // Hour markers + for (int i = 0; i < 12; i++) { + int32_t angle = (i * TRIG_MAX_ANGLE) / 12; + + int marker_length = (i % 3 == 0) ? 10 : 5; // Longer at 12, 3, 6, 9 + int inner_r = CLOCK_RADIUS - marker_length; + int outer_r = CLOCK_RADIUS - 2; + + GPoint inner = { + s_center.x + (sin_lookup(angle) * inner_r) / TRIG_MAX_RATIO, + s_center.y - (cos_lookup(angle) * inner_r) / TRIG_MAX_RATIO + }; + GPoint outer = { + s_center.x + (sin_lookup(angle) * outer_r) / TRIG_MAX_RATIO, + s_center.y - (cos_lookup(angle) * outer_r) / TRIG_MAX_RATIO + }; + + graphics_context_set_stroke_width(ctx, (i % 3 == 0) ? 3 : 1); + graphics_draw_line(ctx, inner, outer); + } +} + +static void draw_clock_hand(GContext *ctx, GPath *path, int32_t angle) { + gpath_rotate_to(path, angle); + gpath_move_to(path, s_center); + + graphics_context_set_fill_color(ctx, GColorWhite); + gpath_draw_filled(ctx, path); + + graphics_context_set_stroke_color(ctx, GColorBlack); + gpath_draw_outline(ctx, path); +} + +static void draw_hands(GContext *ctx, struct tm *time) { + // Hour hand + int32_t hour_angle = ((time->tm_hour % 12) * TRIG_MAX_ANGLE / 12) + + (time->tm_min * TRIG_MAX_ANGLE / 12 / 60); + draw_clock_hand(ctx, s_hour_hand_path, hour_angle); + + // Minute hand + int32_t minute_angle = (time->tm_min * TRIG_MAX_ANGLE / 60) + + (time->tm_sec * TRIG_MAX_ANGLE / 60 / 60); + draw_clock_hand(ctx, s_minute_hand_path, minute_angle); + + // Second hand (optional - uses more battery) + #if SHOW_SECOND_HAND + int32_t second_angle = (time->tm_sec * TRIG_MAX_ANGLE / 60); + int16_t sec_x = s_center.x + (sin_lookup(second_angle) * SECOND_HAND_LENGTH) / TRIG_MAX_RATIO; + int16_t sec_y = s_center.y - (cos_lookup(second_angle) * SECOND_HAND_LENGTH) / TRIG_MAX_RATIO; + + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_context_set_stroke_width(ctx, 1); + graphics_draw_line(ctx, s_center, GPoint(sec_x, sec_y)); + #endif + + // Center dot + graphics_context_set_fill_color(ctx, GColorWhite); + graphics_fill_circle(ctx, s_center, 4); + graphics_context_set_fill_color(ctx, GColorBlack); + graphics_fill_circle(ctx, s_center, 2); +} + +static void draw_decorations(GContext *ctx, GRect bounds) { + // Add any decorative elements here + // Example: Draw a simple border + + #ifdef PBL_ROUND + // Round display decorations + (void)bounds; + #else + // Rectangular display decorations + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_context_set_stroke_width(ctx, 1); + + // Corner accents — use dynamic bounds + int w = bounds.size.w - 5; + int h = bounds.size.h - 5; + graphics_draw_line(ctx, GPoint(5, 5), GPoint(20, 5)); + graphics_draw_line(ctx, GPoint(5, 5), GPoint(5, 20)); + + graphics_draw_line(ctx, GPoint(w, 5), GPoint(w - 15, 5)); + graphics_draw_line(ctx, GPoint(w, 5), GPoint(w, 20)); + + graphics_draw_line(ctx, GPoint(5, h), GPoint(20, h)); + graphics_draw_line(ctx, GPoint(5, h), GPoint(5, h - 15)); + + graphics_draw_line(ctx, GPoint(w, h), GPoint(w - 15, h)); + graphics_draw_line(ctx, GPoint(w, h), GPoint(w, h - 15)); + #endif +} + +// ============================================================================ +// LAYER UPDATE PROCEDURES +// ============================================================================ + +static void canvas_update_proc(Layer *layer, GContext *ctx) { + GRect bounds = layer_get_bounds(layer); + + // Clear background + graphics_context_set_fill_color(ctx, GColorBlack); + graphics_fill_rect(ctx, bounds, 0, GCornerNone); + + // Draw decorations (background) + draw_decorations(ctx, bounds); + + // Draw clock face + draw_clock_face(ctx); + + // Get current time and draw hands + time_t temp = time(NULL); + struct tm *tick_time = localtime(&temp); + if (tick_time) { + draw_hands(ctx, tick_time); + } +} + +static void battery_update_proc(Layer *layer, GContext *ctx) { + const int WIDTH = 20; + const int HEIGHT = 8; + + // Outline + graphics_context_set_stroke_color(ctx, GColorWhite); + graphics_draw_rect(ctx, GRect(0, 0, WIDTH, HEIGHT)); + + // Fill + int fill_width = (s_battery_level * WIDTH) / 100; + graphics_context_set_fill_color(ctx, GColorWhite); + graphics_fill_rect(ctx, GRect(0, 0, fill_width, HEIGHT), 0, GCornerNone); + + // Battery tip + graphics_fill_rect(ctx, GRect(WIDTH, 2, 2, HEIGHT - 4), 0, GCornerNone); +} + +// ============================================================================ +// TIME HANDLING +// ============================================================================ + +static void update_display(void) { + // Update date + time_t temp = time(NULL); + struct tm *tick_time = localtime(&temp); + + if (tick_time && s_date_layer) { + static char date_buffer[16]; + strftime(date_buffer, sizeof(date_buffer), "%a %d", tick_time); + text_layer_set_text(s_date_layer, date_buffer); + } + + // Request canvas redraw + if (s_canvas_layer) { + layer_mark_dirty(s_canvas_layer); + } +} + +static void tick_handler(struct tm *tick_time, TimeUnits units_changed) { + update_display(); +} + +// ============================================================================ +// BATTERY HANDLING +// ============================================================================ + +static void battery_callback(BatteryChargeState state) { + s_battery_level = state.charge_percent; + + if (s_battery_layer) { + layer_mark_dirty(s_battery_layer); + } +} + +// ============================================================================ +// WINDOW HANDLERS +// ============================================================================ + +static void main_window_load(Window *window) { + Layer *window_layer = window_get_root_layer(window); + GRect bounds = layer_get_bounds(window_layer); + + // Calculate center + s_center = GPoint(bounds.size.w / 2, bounds.size.h / 2); + + // Canvas layer + s_canvas_layer = layer_create(bounds); + layer_set_update_proc(s_canvas_layer, canvas_update_proc); + layer_add_child(window_layer, s_canvas_layer); + + // Date layer (below the clock) + GRect date_frame = {{0, bounds.size.h - 30}, {bounds.size.w, 20}}; + s_date_layer = text_layer_create(date_frame); + text_layer_set_text_color(s_date_layer, GColorWhite); + text_layer_set_background_color(s_date_layer, GColorClear); + text_layer_set_font(s_date_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18)); + text_layer_set_text_alignment(s_date_layer, GTextAlignmentCenter); + layer_add_child(window_layer, text_layer_get_layer(s_date_layer)); + + // Battery layer + GRect battery_frame = {{bounds.size.w - 27, 5}, {22, 8}}; + s_battery_layer = layer_create(battery_frame); + layer_set_update_proc(s_battery_layer, battery_update_proc); + layer_add_child(window_layer, s_battery_layer); + + // Initialize hand paths + calculate_hand_points(s_hour_hand_points, HOUR_HAND_LENGTH, 4); + calculate_hand_points(s_minute_hand_points, MINUTE_HAND_LENGTH, 3); + + s_hour_hand_path = gpath_create(&s_hour_hand_info); + s_minute_hand_path = gpath_create(&s_minute_hand_info); + + // Initial display update + update_display(); +} + +static void main_window_unload(Window *window) { + // Destroy paths + if (s_hour_hand_path) { + gpath_destroy(s_hour_hand_path); + s_hour_hand_path = NULL; + } + if (s_minute_hand_path) { + gpath_destroy(s_minute_hand_path); + s_minute_hand_path = NULL; + } + + // Destroy layers + if (s_canvas_layer) { + layer_destroy(s_canvas_layer); + s_canvas_layer = NULL; + } + if (s_date_layer) { + text_layer_destroy(s_date_layer); + s_date_layer = NULL; + } + if (s_battery_layer) { + layer_destroy(s_battery_layer); + s_battery_layer = NULL; + } +} + +// ============================================================================ +// APPLICATION LIFECYCLE +// ============================================================================ + +static void init(void) { + s_main_window = window_create(); + window_set_window_handlers(s_main_window, (WindowHandlers) { + .load = main_window_load, + .unload = main_window_unload + }); + window_stack_push(s_main_window, true); + + // Subscribe to time updates + #if SHOW_SECOND_HAND + tick_timer_service_subscribe(SECOND_UNIT, tick_handler); + #else + tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); + #endif + + battery_state_service_subscribe(battery_callback); + s_battery_level = battery_state_service_peek().charge_percent; +} + +static void deinit(void) { + tick_timer_service_unsubscribe(); + battery_state_service_unsubscribe(); + + if (s_main_window) { + window_destroy(s_main_window); + s_main_window = NULL; + } +} + +int main(void) { + init(); + app_event_loop(); + deinit(); + return 0; +} diff --git a/cloudpebble-agent/skills/pebble-watchface/templates/weather-watchface.c b/cloudpebble-agent/skills/pebble-watchface/templates/weather-watchface.c new file mode 100644 index 0000000..7d3a233 --- /dev/null +++ b/cloudpebble-agent/skills/pebble-watchface/templates/weather-watchface.c @@ -0,0 +1,212 @@ +/** + * Weather Pebble Watchface Template + * + * Displays time, date, and weather information fetched via + * PebbleKit JS from the Open-Meteo API. Requires a companion + * src/pkjs/index.js file for phone-side communication. + * + * Battery-efficient: updates on MINUTE_UNIT only. + * Weather refreshes every 30 minutes. + */ + +#include + +// ============================================================================ +// GLOBAL STATE +// ============================================================================ + +static Window *s_main_window; +static TextLayer *s_time_layer; +static TextLayer *s_date_layer; +static TextLayer *s_weather_layer; +static Layer *s_battery_layer; + +static int s_battery_level = 100; + +// ============================================================================ +// TIME HANDLING +// ============================================================================ + +static void update_time(void) { + time_t temp = time(NULL); + struct tm *tick_time = localtime(&temp); + if (!tick_time) return; + + static char time_buffer[8]; + strftime(time_buffer, sizeof(time_buffer), + clock_is_24h_style() ? "%H:%M" : "%I:%M", tick_time); + text_layer_set_text(s_time_layer, time_buffer); + + static char date_buffer[24]; + strftime(date_buffer, sizeof(date_buffer), "%a, %b %d", tick_time); + text_layer_set_text(s_date_layer, date_buffer); +} + +static void tick_handler(struct tm *tick_time, TimeUnits units_changed) { + update_time(); + + // Request weather update every 30 minutes + if (tick_time->tm_min % 30 == 0) { + DictionaryIterator *iter; + AppMessageResult result = app_message_outbox_begin(&iter); + if (result == APP_MSG_OK) { + dict_write_uint8(iter, MESSAGE_KEY_REQUEST_WEATHER, 1); + app_message_outbox_send(); + } + } +} + +// ============================================================================ +// WEATHER HANDLING (AppMessage) +// ============================================================================ + +static void inbox_received_callback(DictionaryIterator *iterator, void *context) { + Tuple *temp_tuple = dict_find(iterator, MESSAGE_KEY_TEMPERATURE); + Tuple *conditions_tuple = dict_find(iterator, MESSAGE_KEY_CONDITIONS); + + if (temp_tuple && conditions_tuple) { + static char temperature_buffer[8]; + static char conditions_buffer[32]; + static char weather_layer_buffer[42]; + + snprintf(temperature_buffer, sizeof(temperature_buffer), + "%d\u00b0C", (int)temp_tuple->value->int32); + snprintf(conditions_buffer, sizeof(conditions_buffer), + "%s", conditions_tuple->value->cstring); + snprintf(weather_layer_buffer, sizeof(weather_layer_buffer), + "%s %s", temperature_buffer, conditions_buffer); + + text_layer_set_text(s_weather_layer, weather_layer_buffer); + } +} + +static void inbox_dropped_callback(AppMessageResult reason, void *context) { + APP_LOG(APP_LOG_LEVEL_ERROR, "Message dropped!"); +} + +static void outbox_failed_callback(DictionaryIterator *iterator, + AppMessageResult reason, void *context) { + APP_LOG(APP_LOG_LEVEL_ERROR, "Outbox send failed!"); +} + +static void outbox_sent_callback(DictionaryIterator *iterator, void *context) { + APP_LOG(APP_LOG_LEVEL_INFO, "Outbox send success!"); +} + +// ============================================================================ +// BATTERY HANDLING +// ============================================================================ + +static void battery_callback(BatteryChargeState state) { + s_battery_level = state.charge_percent; + if (s_battery_layer) layer_mark_dirty(s_battery_layer); +} + +static void battery_update_proc(Layer *layer, GContext *ctx) { + GRect bounds = layer_get_bounds(layer); + int bar_width = (s_battery_level * bounds.size.w) / 100; + + #ifdef PBL_COLOR + if (s_battery_level <= 20) { + graphics_context_set_fill_color(ctx, GColorRed); + } else if (s_battery_level <= 40) { + graphics_context_set_fill_color(ctx, GColorYellow); + } else { + graphics_context_set_fill_color(ctx, GColorGreen); + } + #else + graphics_context_set_fill_color(ctx, GColorWhite); + #endif + + graphics_fill_rect(ctx, GRect(0, 0, bar_width, bounds.size.h), 0, GCornerNone); +} + +// ============================================================================ +// WINDOW HANDLERS +// ============================================================================ + +static void main_window_load(Window *window) { + Layer *window_layer = window_get_root_layer(window); + GRect bounds = layer_get_bounds(window_layer); + + // Battery bar across top + s_battery_layer = layer_create(GRect(0, 0, bounds.size.w, 3)); + layer_set_update_proc(s_battery_layer, battery_update_proc); + layer_add_child(window_layer, s_battery_layer); + + // Time layer - large centered text + int time_y = PBL_IF_ROUND_ELSE(bounds.size.h / 2 - 50, bounds.size.h / 2 - 55); + s_time_layer = text_layer_create(GRect(0, time_y, bounds.size.w, 50)); + text_layer_set_background_color(s_time_layer, GColorClear); + text_layer_set_text_color(s_time_layer, GColorWhite); + text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_LECO_42_NUMBERS)); + text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter); + layer_add_child(window_layer, text_layer_get_layer(s_time_layer)); + + // Date layer + int date_y = time_y + 52; + s_date_layer = text_layer_create(GRect(0, date_y, bounds.size.w, 26)); + text_layer_set_background_color(s_date_layer, GColorClear); + text_layer_set_text_color(s_date_layer, GColorWhite); + text_layer_set_font(s_date_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD)); + text_layer_set_text_alignment(s_date_layer, GTextAlignmentCenter); + layer_add_child(window_layer, text_layer_get_layer(s_date_layer)); + + // Weather layer + int weather_y = date_y + 30; + s_weather_layer = text_layer_create(GRect(0, weather_y, bounds.size.w, 24)); + text_layer_set_background_color(s_weather_layer, GColorClear); + text_layer_set_text_color(s_weather_layer, GColorWhite); + text_layer_set_font(s_weather_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18)); + text_layer_set_text_alignment(s_weather_layer, GTextAlignmentCenter); + text_layer_set_text(s_weather_layer, "Loading..."); + layer_add_child(window_layer, text_layer_get_layer(s_weather_layer)); + + update_time(); +} + +static void main_window_unload(Window *window) { + text_layer_destroy(s_time_layer); + text_layer_destroy(s_date_layer); + text_layer_destroy(s_weather_layer); + layer_destroy(s_battery_layer); +} + +// ============================================================================ +// APPLICATION LIFECYCLE +// ============================================================================ + +static void init(void) { + s_main_window = window_create(); + window_set_background_color(s_main_window, GColorBlack); + window_set_window_handlers(s_main_window, (WindowHandlers) { + .load = main_window_load, + .unload = main_window_unload + }); + window_stack_push(s_main_window, true); + + tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); + battery_state_service_subscribe(battery_callback); + battery_callback(battery_state_service_peek()); + + // Register AppMessage callbacks BEFORE opening + app_message_register_inbox_received(inbox_received_callback); + app_message_register_inbox_dropped(inbox_dropped_callback); + app_message_register_outbox_failed(outbox_failed_callback); + app_message_register_outbox_sent(outbox_sent_callback); + + app_message_open(128, 128); +} + +static void deinit(void) { + tick_timer_service_unsubscribe(); + battery_state_service_unsubscribe(); + window_destroy(s_main_window); +} + +int main(void) { + init(); + app_event_loop(); + deinit(); + return 0; +} diff --git a/cloudpebble-agent/test_cloudpebble_client.py b/cloudpebble-agent/test_cloudpebble_client.py new file mode 100644 index 0000000..982c1c6 --- /dev/null +++ b/cloudpebble-agent/test_cloudpebble_client.py @@ -0,0 +1,229 @@ +"""Unit test for the build poll state machine. No network. + + python3 test_cloudpebble_client.py +""" + +import json + +import requests + +import cloudpebble_client as cpc +from cloudpebble_client import CloudPebbleClient, CloudPebbleError + +cpc.BUILD_POLL_INTERVAL = 0 + + +class FakeResponse(object): + def __init__(self, body, status=200): + self._body = body + self.status_code = status + self.content = body if isinstance(body, bytes) else json.dumps(body).encode() + + def json(self): + if isinstance(self._body, bytes): + raise ValueError('not json') + return self._body + + +class FakeSession(object): + """Replays a scripted list of (url_fragment -> response|exception).""" + + def __init__(self, script): + self.script = list(script) + self.headers = {} + self.calls = [] + + def request(self, method, url, **kwargs): + self.calls.append((method, url)) + if not self.script: + raise AssertionError('unscripted call: %s %s' % (method, url)) + fragment, result = self.script.pop(0) + assert fragment in url, 'expected %r in %r' % (fragment, url) + if isinstance(result, Exception): + raise result + return result + + def get(self, url, **kwargs): + return self.request('GET', url, **kwargs) + + +def _client(script): + c = CloudPebbleClient('https://cp.example/', 'tok', 7) + c.session = FakeSession(script) + return c + + +def test_build_succeeds(): + c = _client([ + ('build/run', FakeResponse({'build_id': 12, 'task_id': 't'})), + ('build/12/info', FakeResponse({'build': {'id': 12, 'state': 1}})), + ('build/12/info', FakeResponse({'build': {'id': 12, 'state': 3}})), + ('build/12/log', FakeResponse({'log': 'all good'})), + ]) + assert c.build() == ('succeeded', 12, 'all good') + + +def test_build_fails_is_a_result_not_an_exception(): + c = _client([ + ('build/run', FakeResponse({'build_id': 13, 'task_id': 't'})), + ('build/13/info', FakeResponse({'build': {'id': 13, 'state': 2}})), + ('build/13/log', FakeResponse({'log': 'main.c:3: error'})), + ]) + state, build_id, log = c.build() + assert (state, build_id) == ('failed', 13) and 'error' in log + + +def test_a_502_during_the_poll_does_not_kill_the_turn(): + # nginx hands back HTML, or the connection blips: keep polling. + c = _client([ + ('build/run', FakeResponse({'build_id': 14, 'task_id': 't'})), + ('build/14/info', FakeResponse(b'502', status=502)), + ('build/14/info', requests.ConnectionError('boom')), + ('build/14/info', FakeResponse({'build': {'id': 14, 'state': 3}})), + ('build/14/log', FakeResponse({'log': 'ok'})), + ]) + assert c.build() == ('succeeded', 14, 'ok') + + +def test_a_permanent_poll_failure_gives_up_early(): + # An expired token must not spin for the whole BUILD_TIMEOUT. + script = [('build/run', FakeResponse({'build_id': 18, 'task_id': 't'}))] + script += [('build/18/info', FakeResponse({'error': 'forbidden'}, status=403)) + for _ in range(cpc.BUILD_POLL_MISSES)] + c = _client(script) + state, build_id, log = c.build() + assert state == 'unknown' and build_id == 18 and 'lost contact' in log + assert not c.session.script, 'should have stopped at BUILD_POLL_MISSES' + + +def test_timeout_is_unknown_not_failed(): + # 'failed' would send the model off fixing code that compiled fine. + cpc.BUILD_TIMEOUT, saved = 0, cpc.BUILD_TIMEOUT + try: + c = _client([('build/run', FakeResponse({'build_id': 15, 'task_id': 't'}))]) + state, build_id, log = c.build() + finally: + cpc.BUILD_TIMEOUT = saved + assert state == 'unknown' and build_id == 15 and 'did not finish' in log + + +def test_transport_failures_become_cloudpebble_errors(): + c = _client([('project/7/info', requests.ConnectTimeout('dns'))]) + try: + c.info() + except CloudPebbleError as e: + assert 'dns' in str(e) + else: + raise AssertionError('expected CloudPebbleError') + + +def test_download_name_follows_the_project_type(): + c = _client([ + ('project/7/info', FakeResponse({'type': 'package'})), + ('build/16/download/package.tar.gz', FakeResponse(b'tarball')), + ]) + assert c.download_pbw(16) == b'tarball' + + c = _client([ + ('project/7/info', FakeResponse({'type': 'native'})), + ('build/17/download/watchface.pbw', FakeResponse(b'pbw')), + ]) + assert c.download_pbw(17) == b'pbw' + + +class RecordingSession(FakeSession): + """Like FakeSession, but keeps the request bodies so we can assert on them.""" + + def request(self, method, url, **kwargs): + self.calls.append((method, url, kwargs.get('data'), kwargs.get('files'))) + if not self.script: + raise AssertionError('unscripted call: %s %s' % (method, url)) + fragment, result = self.script.pop(0) + assert fragment in url, 'expected %r in %r' % (fragment, url) + if isinstance(result, Exception): + raise result + return result + + +def _recording(script): + c = CloudPebbleClient('https://cp.example/', 'tok', 7) + c.session = RecordingSession(script) + return c + + +def test_paths_split_into_the_target_and_name_the_server_expects(): + # CloudPebble stores (target, name-under-that-target's-dir) and validates the + # pair. Sending the whole path with target=app is what made writing pkjs fail. + assert cpc.split_path('native', 'src/pkjs/index.js') == ('pkjs', 'index.js') + assert cpc.split_path('native', 'src/c/main.c') == ('app', 'main.c') + assert cpc.split_path('native', 'worker_src/c/worker.c') == ('worker', 'worker.c') + assert cpc.split_path('alloy', 'src/embeddedjs/main.js') == ('embeddedjs', 'main.js') + assert cpc.split_path('alloy', 'src/pkjs/index.js') == ('pkjs', 'index.js') + # A bare name has no directory to read a target from. + assert cpc.split_path('native', 'main.c') == (None, 'main.c') + + +def test_a_new_pkjs_file_is_created_as_pkjs(): + c = _recording([ + ('project/7/info', FakeResponse({'type': 'native', 'source_files': []})), + ('create_source_file', FakeResponse({'file': {'id': 4}})), + ]) + c.write_file('src/pkjs/index.js', 'var x = 1;') + method, url, data, _files = c.session.calls[-1] + assert data == {'name': 'index.js', 'target': 'pkjs', 'content': 'var x = 1;'} + + +def test_a_binary_path_is_refused_with_a_pointer_to_the_right_tool(): + c = _client([ + ('project/7/info', FakeResponse({'type': 'native', 'source_files': []})), + ]) + try: + c.write_file('src/c/logo.png', 'not text') + except CloudPebbleError as e: + assert 'write_binary_file' in str(e) + else: + raise AssertionError('expected CloudPebbleError') + + +def test_a_new_resource_gets_an_identifier_derived_from_its_name(): + c = _recording([ + ('project/7/info', FakeResponse({'type': 'native', 'resources': []})), + ('create_resource', FakeResponse({'file': {'id': 3, 'identifiers': ['SPACE_BG']}})), + ]) + resource, replaced = c.write_resource('space-bg.png', 'png', b'\x89PNG') + assert replaced is False + _method, _url, data, files = c.session.calls[-1] + assert json.loads(data['resource_ids']) == [{'id': 'SPACE_BG'}] + assert data['kind'] == 'png' + assert 'file' in files + + +def test_replacing_a_resource_keeps_the_ids_the_code_already_uses(): + existing = {'id': 3, 'file_name': 'bg.png', 'identifiers': ['IMAGE_BG'], + 'extra': {'IMAGE_BG': {'memory_format': '8Bit'}}} + c = _recording([ + ('project/7/info', FakeResponse({'type': 'native', 'resources': [existing]})), + ('resource/3/update', FakeResponse({'file': {'id': 3, 'identifiers': ['IMAGE_BG']}})), + ]) + _resource, replaced = c.write_resource('bg.png', 'png', b'new bytes') + assert replaced is True + _method, _url, data, _files = c.session.calls[-1] + assert json.loads(data['resource_ids']) == [{'memory_format': '8Bit', 'id': 'IMAGE_BG'}] + + +def test_an_unknown_resource_kind_is_refused_before_the_upload(): + c = _client([]) + try: + c.write_resource('x.png', 'jpeg', b'') + except CloudPebbleError as e: + assert 'unknown resource kind' in str(e) + else: + raise AssertionError('expected CloudPebbleError') + + +if __name__ == '__main__': + for name, fn in sorted(globals().items()): + if name.startswith('test_'): + fn() + print('ok %s' % name) + print('PASSED') diff --git a/cloudpebble-agent/test_emulator_framing.py b/cloudpebble-agent/test_emulator_framing.py new file mode 100644 index 0000000..6bec5ac --- /dev/null +++ b/cloudpebble-agent/test_emulator_framing.py @@ -0,0 +1,187 @@ +"""Unit test for the emulator wire framing and screenshot assembly. No network. + + python3 test_emulator_framing.py +""" + +import struct +import zlib + +from emulator import (BUTTONS, COLOUR_MAP, ENDPOINT_APP_LOGS, ENDPOINT_SCREENSHOT, + NoEmulator, OP_QEMU, QEMU_BUTTON, QEMU_TAP, TAP_AXES, + OP_FROM_WATCH, OP_TO_WATCH, ROUNDNESS, auth_frame, decode_app_log, + get_emulator, parse_inbound, screenshot_header, screenshot_png, + to_watch, ws_url) + + +def test_auth_frame(): + assert auth_frame('abc') == b'\x09\x03abc' + + +def test_to_watch(): + frame = to_watch(ENDPOINT_SCREENSHOT, b'\x00') + assert frame == bytes([OP_TO_WATCH]) + struct.pack('>HH', 1, 8000) + b'\x00' + + +def test_parse_inbound(): + # 0x00 is from the watch; 0x01 is outbound-only and must not be mistaken for it. + inbound = bytes([OP_FROM_WATCH]) + struct.pack('>HH', 3, 2006) + b'abc' + b'trailing' + assert parse_inbound(inbound) == (OP_FROM_WATCH, 2006, b'abc') + op, endpoint, payload = parse_inbound(to_watch(8000, b'\x00')) + assert op == OP_TO_WATCH and endpoint is None + # opcodes without a pebble header hand back the raw tail + assert parse_inbound(b'\x02hello') == (0x02, None, b'hello') + assert parse_inbound(b'') == (None, None, b'') + + +def test_app_log_decode(): + payload = (b'\x00' * 16 + + struct.pack('>IBBH', 1700000000, 100, 5, 42) + + b'main.c'.ljust(16, b'\x00') + + b'hello') + assert decode_app_log(payload) == '[INFO] main.c:42 hello' + # short/garbage payloads degrade instead of blowing up mid-turn + assert decode_app_log(b'oops') == 'oops' + + +def _png_pixels(png): + assert png[:8] == b'\x89PNG\r\n\x1a\n' + width, height, depth, colour = struct.unpack('>IIBB', png[16:26]) + assert (depth, colour) == (8, 2) + pos, idat = 8, b'' + while pos < len(png): + length = struct.unpack('>I', png[pos:pos + 4])[0] + tag = png[pos + 4:pos + 8] + if tag == b'IDAT': + idat += png[pos + 8:pos + 8 + length] + pos += 12 + length + raw = zlib.decompress(idat) + stride = width * 3 + 1 + rows = [] + for y in range(height): + assert raw[y * stride] == 0, 'expected filter type 0' + rows.append(raw[y * stride + 1:(y + 1) * stride]) + return width, height, rows + + +def test_screenshot_v2_assembly(): + # 2x2 8bpp frame: palette indices 0 (black), 63 (white), 48, 3. + pixels = bytes([0b00000000, 0b00111111, 0b00110000, 0b00000011]) + header = struct.pack('>BIII', 0, 2, 2, 2) + + # the watch splits the image across several 0x00 frames on endpoint 8000 + frames = [bytes([OP_FROM_WATCH]) + struct.pack('>HH', len(header) + 2, ENDPOINT_SCREENSHOT) + header + pixels[:2], + bytes([OP_FROM_WATCH]) + struct.pack('>HH', 2, ENDPOINT_SCREENSHOT) + pixels[2:], + bytes([OP_FROM_WATCH]) + struct.pack('>HH', 3, ENDPOINT_APP_LOGS) + b'xxx'] + + buf, meta, expected = b'', None, None + for frame in frames: + op, endpoint, data = parse_inbound(frame) + if op != OP_FROM_WATCH or endpoint != ENDPOINT_SCREENSHOT: + continue + if meta is None: + version, width, height, expected, data = screenshot_header(data) + meta = (version, width, height) + buf += data + assert meta == (2, 2, 2) + assert expected == 4 and buf == pixels + + width, height, rows = _png_pixels(screenshot_png(2, 2, 2, buf)) + assert (width, height) == (2, 2) + + def rgb(index): + c = COLOUR_MAP[index] + return bytes([(c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF]) + + assert rows[0] == rgb(0) + rgb(63) == bytes([0, 0, 0]) + bytes([255, 255, 255]) + # The panel-corrected palette, not the naive x85 map: index 48 is a muted red in + # the IDE, and the model must judge its colours against what the user sees. + assert rows[1] == rgb(48) + rgb(3) + assert rows[1] != bytes([255, 0, 0]) + bytes([0, 0, 255]) + + +def test_chalk_corners_are_masked(): + # 180px wide is the round display; the bezel clips ROUNDNESS[y] pixels each side. + pixels = bytes([63]) * (180 * 180) + width, height, rows = _png_pixels(screenshot_png(2, 180, 180, pixels)) + assert (width, height) == (180, 180) + skip = ROUNDNESS[0] + assert rows[0][:skip * 3] == b'\x00' * (skip * 3) + assert rows[0][skip * 3:skip * 3 + 3] == b'\xff\xff\xff' + assert rows[-1][-skip * 3:] == b'\x00' * (skip * 3) + # The middle rows are untouched. + assert rows[90] == b'\xff' * (180 * 3) + + +def test_get_emulator_rejects_a_spec_with_no_uuid_or_token(): + for spec in (None, {}, {'uuid': 'abc'}, {'token': 'x'}): + try: + get_emulator(spec, 'https://cloudpebble-dev.exe.xyz/') + except NoEmulator as e: + assert 'no emulator is running' in str(e) + else: + raise AssertionError('expected NoEmulator for %r' % (spec,)) + + +def test_screenshot_v1_assembly(): + # 8x1 1bpp: alternating pixels, bit 0 is the leftmost pixel. + pixels = bytes([0b01010101]) + width, height, rows = _png_pixels(screenshot_png(1, 8, 1, pixels)) + assert (width, height) == (8, 1) + assert rows[0] == b''.join(bytes([255] * 3) if x % 2 == 0 else bytes([0] * 3) for x in range(8)) + + +def test_screenshot_error_code(): + try: + screenshot_header(struct.pack('>BIII', 1, 2, 144, 168)) + except Exception as e: + assert 'error code 1' in str(e) + else: + raise AssertionError('expected a NoEmulator for a non-zero status code') + + +def test_ws_url(): + assert ws_url('https://cloudpebble-dev.exe.xyz/', 'abc') == 'wss://cloudpebble-dev.exe.xyz/qemu/abc/ws/phone' + + + + +def test_button_bits_match_what_the_watch_expects(): + """The payload is the set of buttons currently HELD, and the bits are + libpebble2's QemuButton.Button values -- not the 0..3 indices the IDE's own + JS uses before it shifts them.""" + assert BUTTONS == {'back': 1, 'up': 2, 'select': 4, 'down': 8}, BUTTONS + # The IDE computes 1 << Pebble.Button.X; the two must agree. + js_indices = {'back': 0, 'up': 1, 'select': 2, 'down': 3} + for name, index in js_indices.items(): + assert BUTTONS[name] == 1 << index, name + assert set(TAP_AXES) == {'x', 'y', 'z'} + + +def test_qemu_control_frames_are_opcode_protocol_payload(): + """0x0b , on the same socket as everything else.""" + press = bytes([OP_QEMU, QEMU_BUTTON, BUTTONS['select']]) + assert press == b'\x0b\x08\x04', press + release = bytes([OP_QEMU, QEMU_BUTTON, 0]) + assert release == b'\x0b\x08\x00', release + shake = bytes([OP_QEMU, QEMU_TAP, TAP_AXES['y'], 1]) + assert shake == b'\x0b\x02\x01\x01', shake + + +def test_app_log_shipping_is_requested_the_way_the_browser_does(): + """Nothing arrives on logs() until the watch is told to ship APP_LOG output. + The browser sends APP_LOGS=1 after every install (libpebble.js:enable_app_logs); + the agent has to send the same frame or it debugs blind.""" + frame = to_watch(ENDPOINT_APP_LOGS, b'\x01') + op, endpoint, payload = parse_inbound(b'\x00' + frame[1:]) + assert frame[0] == 0x01, frame[0] + assert endpoint == 2006, endpoint + assert payload == b'\x01', payload + print('app log shipping frame correct') + + +if __name__ == '__main__': + for name, fn in sorted(globals().items()): + if name.startswith('test_'): + fn() + print('ok %s' % name) + print('PASSED') diff --git a/cloudpebble-agent/test_error_kinds.py b/cloudpebble-agent/test_error_kinds.py new file mode 100644 index 0000000..b185c05 --- /dev/null +++ b/cloudpebble-agent/test_error_kinds.py @@ -0,0 +1,66 @@ +"""_kind() decides what the user is told to do, so it gets a test. + +A usage limit means wait; an auth failure means replace your credential. Telling +someone to re-authenticate when they merely ran out of quota sends them chasing +a key that was fine, and the reverse leaves them retrying a dead token forever. +""" +import re +import pathlib + + +def _kind_source(): + """Read the classifier out of agent_loop without importing the SDK.""" + src = pathlib.Path(__file__).with_name('agent_loop.py').read_text() + ns = {} + for name in ('USAGE_LIMIT_HINTS', 'AUTH_HINTS'): + block = re.search(r'%s = \((.*?)\)\n' % name, src, re.S).group(1) + ns[name] = tuple(re.findall(r"'([^']+)'", block)) + body = re.search(r'def _kind\(text\):\n(.*?)\n\n', src, re.S).group(0) + exec(body, ns) + return ns['_kind'] + + +CASES = [ + # observed verbatim from providers during benchmarking + ('authentication_failed', 'auth'), + ('Failed to authenticate. API Error: 401 User not found.', 'auth'), + ('invalid x-api-key', 'auth'), + ('Your credit balance is too low', 'error'), + ('request reached organization TPD rate limit, current: 1508283', 'usage_limit'), + ('API Error: Request rejected (429) ... rate limit', 'usage_limit'), + ('Build failed: syntax error in main.c', 'error'), + ('', 'error'), +] + + +def test_kinds(): + kind = _kind_source() + for text, expected in CASES: + got = kind(text) + assert got == expected, '%r -> %s, want %s' % (text[:50], got, expected) + + + + +def test_a_reported_step_limit_is_not_repeated_in_sdk_words(): + """The SDK raises after the result message it already described. Saying + "Reached maximum number of turns (75)" straight after the friendly sentence + is two errors for one event.""" + reported = {'max_turns'} + raw = 'Claude Code returned an error result: Reached maximum number of turns (75)' + suppressed = 'max_turns' in reported and 'maximum number of turns' in raw + assert suppressed, 'the duplicate should be suppressed' + + # An unrelated failure after a step limit must still be reported. + other = 'Claude Code returned an error result: connection reset' + assert not ('max_turns' in reported and 'maximum number of turns' in other) + + # And with nothing reported yet, the raw message is all the user would get. + assert not (set() and 'maximum number of turns' in raw) + print('step limit reported once') + + +if __name__ == '__main__': + test_kinds() + print('%d error classifications correct' % len(CASES)) + test_a_reported_step_limit_is_not_repeated_in_sdk_words() diff --git a/cloudpebble-agent/test_project_state.py b/cloudpebble-agent/test_project_state.py new file mode 100644 index 0000000..f4178b7 --- /dev/null +++ b/cloudpebble-agent/test_project_state.py @@ -0,0 +1,82 @@ +"""The state block is the only thing that tells the agent what it is building. + +Get it wrong and the model lays out for the wrong screen or ships a watch app +believing it is a watchface -- both of which look fine in the screenshot. +""" +import project_state + + +INFO = { + 'type': 'native', + 'name': 'Space Face', + 'app_uuid': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 'app_is_watchface': True, + 'app_platforms': 'emery,chalk', + 'supported_platforms': ['aplite', 'basalt', 'chalk', 'diorite', 'emery', 'flint', 'gabbro'], + 'app_keys': '["TEMPERATURE"]', + 'app_capabilities': 'location', + 'app_dependencies': {'@moddable/pebbleproxy': '^0.1.3'}, + 'source_files': [{'file_path': 'src/c/main.c', 'target': 'app'}], +} + + +def test_screen_sizes_are_spelled_out_per_enabled_platform(): + block = project_state.render(INFO) + assert '200x228' in block # emery + assert '180x180' in block # chalk + assert 'round' in block # chalk is round, and that changes the layout + assert '144x168' not in block # not a target here, so not a size to design for + + +def test_a_watch_app_is_called_out_and_a_watchface_is_not(): + app = dict(INFO, app_is_watchface=False) + assert 'app_is_watchface=true' in project_state.render(app) + assert 'app_is_watchface=true' not in project_state.render(INFO) + + +def test_alloy_projects_are_named_as_such(): + block = project_state.render(dict(INFO, type='alloy')) + assert 'Alloy' in block + assert 'cannot change it' in block + + +def test_the_running_emulator_is_reported_with_its_size(): + block = project_state.render(INFO, {'platform': 'gabbro'}) + assert '260x260' in block + + +def test_no_emulator_says_so_rather_than_going_quiet(): + block = project_state.render(INFO, None) + assert 'no emulator' in block + assert 'build() still works' in block + + +def test_an_emulator_of_unknown_platform_does_not_invent_one(): + block = project_state.render(INFO, {'uuid': 'x', 'token': 'y', 'platform': ''}) + assert 'not reported' in block + assert '200x228' in block # from the target list, not from the emulator + + +def test_settings_and_files_both_survive(): + block = project_state.render(INFO) + assert 'src/c/main.c' in block + assert 'location' in block + assert '@moddable/pebbleproxy@^0.1.3' in block + + +def test_writable_paths_are_named_so_a_refused_write_is_recoverable(): + # An agent told only "Unacceptable file extension for app file in [src/x.js]" + # concluded it could not write pkjs at all and shipped without its weather JS. + assert 'src/pkjs/*.js' in project_state.render(INFO) + assert 'src/embeddedjs/main.js' in project_state.render(dict(INFO, type='alloy')) + + +def test_no_info_renders_nothing(): + assert project_state.render(None) == '' + + +if __name__ == '__main__': + for name, fn in sorted(globals().items()): + if name.startswith('test_'): + fn() + print('ok', name) diff --git a/cloudpebble-agent/test_tool_registry.py b/cloudpebble-agent/test_tool_registry.py new file mode 100644 index 0000000..b3b31ae --- /dev/null +++ b/cloudpebble-agent/test_tool_registry.py @@ -0,0 +1,66 @@ +"""The allow-list and the registered tools must not drift. + +permission_mode='dontAsk' denies any tool absent from allowed_tools, which is +built from TOOL_NAMES. Declaring a @tool without adding it to TOOL_NAMES means +the model sees the tool, calls it, and is silently denied -- which is exactly +what happened to set_app_settings, so the agent could not flag a project as a +watchface and quietly shipped watch apps instead. +""" +import pathlib +import re + + +def test_tool_names_match_declared_tools(): + src = pathlib.Path(__file__).with_name('tools.py').read_text() + declared = set(re.findall(r"@tool\('([a-z_]+)'", src)) + listed = re.search(r'TOOL_NAMES = \[(.*?)\]', src, re.S).group(1) + listed = {n.strip().strip("'\"") for n in listed.split(',') if n.strip()} + listed = {n for n in listed if n and not n.startswith('#')} + assert declared == listed, ( + 'tools.py drift: declared-but-not-allowed=%s allowed-but-not-declared=%s' + % (sorted(declared - listed), sorted(listed - declared))) + + registered = re.search(r'tools=\[(.*?)\]', src, re.S).group(1) + registered = {n.strip() for n in registered.replace('\n', ' ').split(',') if n.strip()} + assert registered == declared, ( + 'server registration drift: %s' % sorted(registered ^ declared)) + + + + +def test_reference_paths_resolve_and_stay_inside_the_skills_directory(): + """The model reaches for these by three different spellings, and must not be + able to reach anything else.""" + import os + import tools + + root = tools.SKILLS_ROOT + if not os.path.isdir(root): + print('skip: no skills directory in this checkout') + return + + # The shapes the model actually types. + for name in ('reference/alloy-guide.md', + 'pebble-watchface/reference/alloy-guide.md', + os.path.join(root, 'pebble-watchface/reference/alloy-guide.md')): + resolved = tools._reference_path(name) + assert resolved.startswith(root + os.sep), (name, resolved) + assert os.path.isfile(resolved), name + + # And the shapes an escape would use. + for bad in ('../../../etc/passwd', '/etc/passwd', 'reference/../../../../etc/passwd'): + try: + tools._reference_path(bad) + except ValueError: + continue + raise AssertionError('escaped the skills directory: %s' % bad) + + index = tools._reference_index() + assert any(p.endswith('alloy-guide.md') for p in index), index + print('reference paths resolve, escapes refused') + + +if __name__ == '__main__': + test_tool_names_match_declared_tools() + print('tool registry consistent') + test_reference_paths_resolve_and_stay_inside_the_skills_directory() diff --git a/cloudpebble-agent/tools.py b/cloudpebble-agent/tools.py new file mode 100644 index 0000000..ba69de3 --- /dev/null +++ b/cloudpebble-agent/tools.py @@ -0,0 +1,492 @@ +"""In-process MCP server: the only actions the agent can take. + +No filesystem, no shell. Every tool is an HTTPS call to CloudPebble or a frame on +the emulator websocket. Tools also push rendered events onto ctx.events, which +agent_loop drains into the SSE stream the chat panel reads. +""" + +import asyncio +import base64 +import difflib +import json +import logging +import os +from typing import Annotated + +from claude_agent_sdk import create_sdk_mcp_server, tool + +# Vision is not universal: several strong tool-calling models on OpenRouter are +# text-only, and handing them an image block means they "verify" a screenshot +# they never saw. Set AGENT_MODEL_VISION=0 for those. +MODEL_HAS_VISION = os.environ.get('AGENT_MODEL_VISION', '1') not in ('0', 'false', 'no') + +import emulator as emu +import project_state +import vision +from cloudpebble_client import CloudPebbleClient, CloudPebbleError + +logger = logging.getLogger(__name__) + +SERVER_NAME = 'cloudpebble' +# Every tool the server exposes must be listed here: ALLOWED_TOOLS drives +# permission_mode='dontAsk', which denies anything absent. Adding a @tool without +# adding it here means the model sees the tool, calls it, and gets denied. +TOOL_NAMES = ['list_files', 'set_app_settings', 'read_file', 'write_file', + 'delete_file', 'write_binary_file', 'write_resource', 'delete_resource', + 'read_reference', 'build', 'install', 'press', 'screenshot', 'logs'] +ALLOWED_TOOLS = ['mcp__%s__%s' % (SERVER_NAME, n) for n in TOOL_NAMES] + +MAX_LOG_CHARS = 20000 + + +class Context(object): + def __init__(self, cp_base_url, cp_token, project_id, emulator): + self.cp = CloudPebbleClient(cp_base_url, cp_token, project_id) + self.cp_base_url = cp_base_url + self.emulator = emulator + self.events = [] + self.last_build_id = None + # Describer calls are a real cost on a model that cannot see: one paid + # call per screenshot, to a different provider than the main model. + self.vision_calls = 0 + self.vision_cost_usd = 0.0 + # Per-turn, from the caller: whether the acting model can see, and which + # describer stands in when it cannot. Falls back to the container's own + # configuration when the caller says nothing. + self.model_vision = MODEL_HAS_VISION + self.vision_config = None + + def add_vision_cost(self, usage): + self.vision_calls += 1 + cost = (usage or {}).get('cost_usd') + if isinstance(cost, (int, float)): + self.vision_cost_usd += cost + + def emit(self, role, type_, data): + self.events.append({'role': role, 'type': type_, 'data': data}) + + +def _text(s): + return {'content': [{'type': 'text', 'text': s}]} + + +def _error(ctx, name, message): + ctx.emit('tool', 'tool_result', {'tool': name, 'ok': False, 'summary': message}) + return {'content': [{'type': 'text', 'text': 'Error: %s' % message}], 'is_error': True} + + +def _ok(ctx, name, summary, text=None, extra_content=None): + ctx.emit('tool', 'tool_result', {'tool': name, 'ok': True, 'summary': summary}) + content = [{'type': 'text', 'text': text if text is not None else summary}] + return {'content': (extra_content or []) + content} + + +def _decode(b64): + """Base64 from the model. Whitespace and data: prefixes are common and harmless.""" + raw = (b64 or '').strip() + if raw.startswith('data:'): + raw = raw.split(',', 1)[-1] + try: + return base64.b64decode(''.join(raw.split()), validate=True) + except Exception: + raise ValueError('content_base64 is not valid base64') + + +def _resource_ids(raw): + """The identifiers a resource is known by in code. None means 'leave them'.""" + if not raw or not raw.strip(): + return None + try: + parsed = json.loads(raw) + except ValueError: + raise ValueError('resource_ids must be JSON, e.g. [{"id": "IMAGE_BACKGROUND"}]') + if isinstance(parsed, str): + parsed = [parsed] + if not isinstance(parsed, list): + raise ValueError('resource_ids must be a JSON list') + out = [] + for entry in parsed: + if isinstance(entry, str): + out.append({'id': entry}) + elif isinstance(entry, dict) and entry.get('id'): + out.append(entry) + else: + raise ValueError('each resource id needs an "id", e.g. {"id": "IMAGE_BACKGROUND"}') + return out + + +# The skill ships reference guides and templates the model is told to read, but +# it has no filesystem: Read is banned and read_file only sees CloudPebble project +# files. On the first real Alloy project the model tried both routes, failed, and +# then spent an entire 30-step budget rediscovering by trial that +# `import Battery from "battery"` does not exist -- which is what the guide it +# could not open says. So serve them. +WORKSPACE = os.environ.get('AGENT_WORKSPACE', '/opt/agent-workspace') +SKILLS_ROOT = os.path.realpath(os.path.join(WORKSPACE, '.claude', 'skills')) +MAX_REFERENCE_CHARS = 120000 + + +def _reference_path(name): + """Resolve a name under the skills directory, or raise. Never escapes it.""" + raw = (name or '').strip() + # Tolerate the shapes the model actually types: the absolute path it saw in an + # error message, a path relative to the skills root, a path relative to the + # skill itself, or a bare file name. + relative = raw[len(SKILLS_ROOT):] if raw.startswith(SKILLS_ROOT) else raw + relative = relative.lstrip('/') + candidates = [relative] + if os.path.isdir(SKILLS_ROOT): + for skill in sorted(os.listdir(SKILLS_ROOT)): + candidates.append(os.path.join(skill, relative)) + for candidate in candidates: + full = os.path.realpath(os.path.join(SKILLS_ROOT, candidate)) + if full.startswith(SKILLS_ROOT + os.sep) and os.path.isfile(full): + return full + raise ValueError('no such reference: %s' % name) + + +def _reference_index(): + out = [] + for root, _dirs, files in os.walk(SKILLS_ROOT): + for f in sorted(files): + out.append(os.path.relpath(os.path.join(root, f), SKILLS_ROOT)) + return sorted(out) + + +async def _connect(ctx): + """The emulator connection, re-resolving the descriptor if it has gone stale. + + The one handed to the turn dies whenever the user reboots or reopens the + emulator, and a turn runs for minutes. Without this, one reboot poisons every + remaining install and screenshot in the turn. + """ + try: + return await asyncio.to_thread(emu.get_emulator, ctx.emulator, ctx.cp_base_url) + except emu.NoEmulator: + fresh = await asyncio.to_thread( + ctx.cp.live_emulator, (ctx.emulator or {}).get('platform')) + if not fresh or fresh.get('uuid') == (ctx.emulator or {}).get('uuid'): + raise + logger.info('emulator changed under this turn, retrying with %s', fresh['uuid']) + ctx.emulator = fresh + return await asyncio.to_thread(emu.get_emulator, ctx.emulator, ctx.cp_base_url) + + +def _changed(ctx, what): + """Tell the IDE its view of the project is out of date. + + write_file already says so through its file_edit event, which carries a diff. + These changes have no diff to show -- a delete, an uploaded image, a settings + write -- but the sidebar, the resource list and the open editor buffer are + just as stale without them. + """ + ctx.emit('tool', 'project_changed', {'what': what}) + + +def _tail(s, limit=MAX_LOG_CHARS): + return s if len(s) <= limit else '...(truncated)...\n' + s[-limit:] + + +def build_server(ctx): + """Build the MCP server bound to one turn's context.""" + + @tool('list_files', + "The project's current state: settings, target platforms and screen sizes, " + "source files, and which watch the emulator is running.", {}) + async def list_files(args): + try: + info = await asyncio.to_thread(ctx.cp.info) + except CloudPebbleError as e: + return _error(ctx, 'list_files', str(e)) + files = info.get('source_files') or [] + return _ok(ctx, 'list_files', '%d files' % len(files), + project_state.render(info, ctx.emulator)) + + @tool('set_app_settings', + "Change this project's settings. Only the fields you pass are touched. " + "A watchface MUST have app_is_watchface=true or it installs as an app " + "and never appears as a face. Scoped to this project alone.", + {'app_is_watchface': Annotated[bool, 'True for a watchface, False for a watch app'], + 'app_platforms': Annotated[str, 'Comma-separated, e.g. "basalt,emery". Empty = all'], + 'app_long_name': Annotated[str, 'Display name on the watch'], + 'app_short_name': Annotated[str, 'Short name for the launcher'], + 'app_company_name': Annotated[str, 'Author/company name'], + 'app_version_label': Annotated[str, 'Version string, e.g. "1.2"'], + 'app_capabilities': Annotated[str, 'Comma-separated: location, health, configurable'], + 'app_keys': Annotated[str, 'AppMessage keys as a JSON list or object'], + 'app_is_hidden': Annotated[bool, 'Hide from the launcher'], + 'app_is_shown_on_communication': Annotated[bool, 'Show on communication events'], + 'app_modern_multi_js': Annotated[bool, 'Use the modern multi-JS build'], + 'menu_icon': Annotated[str, 'Resource id of a project resource to use as menu icon'], + 'app_uuid': Annotated[str, 'Project UUID'], + 'app_dependencies': Annotated[str, 'npm packages as a JSON object, e.g. ' + '{"@moddable/pebbleproxy": "^0.1.3"}. ' + 'Replaces the whole list.'], + 'name': Annotated[str, 'CloudPebble project name']}) + async def set_app_settings(args): + fields = {k: v for k, v in args.items() if v is not None and v != ''} + if not fields: + return _error(ctx, 'set_app_settings', 'no settings given') + try: + res = await asyncio.to_thread(lambda: ctx.cp.set_app_settings(**fields)) + except CloudPebbleError as e: + return _error(ctx, 'set_app_settings', str(e)) + changed = res.get('changed', {}) + # Settings drive the sidebar's own state (project type, platforms, the + # resource list's menu icon), so the IDE needs the same nudge. + _changed(ctx, 'settings') + return _ok(ctx, 'set_app_settings', + ', '.join('%s=%s' % kv for kv in changed.items()) or 'no change') + + @tool('read_file', 'Read a source file.', + {'path': Annotated[str, 'File name or project path, e.g. src/c/main.c']}) + async def read_file(args): + path = args['path'] + try: + content = await asyncio.to_thread(ctx.cp.read_file, path) + except CloudPebbleError as e: + return _error(ctx, 'read_file', str(e)) + return _ok(ctx, 'read_file', path, content) + + @tool('write_file', 'Create or overwrite a source file. Always write the whole file.', + {'path': Annotated[str, 'File name or project path, e.g. src/c/main.c'], + 'content': Annotated[str, 'Full new contents of the file']}) + async def write_file(args): + path, content = args['path'], args['content'] + try: + file_id, old = await asyncio.to_thread(ctx.cp.write_file, path, content) + except CloudPebbleError as e: + return _error(ctx, 'write_file', str(e)) + diff = ''.join(difflib.unified_diff((old or '').splitlines(True), + content.splitlines(True), + 'a/' + path, 'b/' + path)) + ctx.emit('tool', 'file_edit', {'path': path, 'file_id': file_id, 'diff': diff}) + return _ok(ctx, 'write_file', '%s (%s)' % (path, 'created' if old is None else 'updated')) + + @tool('delete_file', 'Delete a source file.', {'path': Annotated[str, 'File name or project path']}) + async def delete_file(args): + try: + await asyncio.to_thread(ctx.cp.delete_file, args['path']) + except CloudPebbleError as e: + return _error(ctx, 'delete_file', str(e)) + _changed(ctx, 'files') + return _ok(ctx, 'delete_file', 'deleted %s' % args['path']) + + @tool('write_binary_file', + "Create or replace a non-text source file -- an Alloy asset under " + "src/embeddedjs, such as a .png, .pdc or .ttf. Images the app draws with " + "resource ids go through write_resource instead.", + {'path': Annotated[str, 'Project path, e.g. src/embeddedjs/dial.png'], + 'content_base64': Annotated[str, 'The file, base64 encoded']}) + async def write_binary_file(args): + path = args['path'] + try: + data = _decode(args['content_base64']) + file_id, replaced = await asyncio.to_thread(ctx.cp.write_binary_file, path, data) + except (CloudPebbleError, ValueError) as e: + return _error(ctx, 'write_binary_file', str(e)) + _changed(ctx, 'files') + return _ok(ctx, 'write_binary_file', '%s (%d bytes, %s)' + % (path, len(data), 'replaced' if replaced else 'created')) + + @tool('write_resource', + "Add or replace a project resource: an image, font or raw blob the app " + "loads by resource id. Replacing keeps the existing ids unless you pass " + "new ones. Keep assets small -- they travel base64 through this tool, and " + "drawing in code is usually cheaper than shipping a bitmap.", + {'file_name': Annotated[str, 'Resource file name, e.g. background.png'], + 'kind': Annotated[str, 'bitmap, png, png-trans, font, pbi or raw'], + 'content_base64': Annotated[str, 'The file, base64 encoded'], + 'resource_ids': Annotated[str, 'Optional JSON list of identifiers, e.g. ' + '[{"id": "IMAGE_BACKGROUND"}] or with options: ' + '[{"id": "FONT_BIG", "tracking": 1}]. ' + 'Defaults to one id derived from the file name.']}) + async def write_resource(args): + name, kind = args['file_name'], args['kind'] + try: + data = _decode(args['content_base64']) + ids = _resource_ids(args.get('resource_ids')) + resource, replaced = await asyncio.to_thread( + ctx.cp.write_resource, name, kind, data, ids) + except (CloudPebbleError, ValueError) as e: + return _error(ctx, 'write_resource', str(e)) + _changed(ctx, 'resources') + return _ok(ctx, 'write_resource', + '%s (%s, %d bytes, %s)' % (name, kind, len(data), + 'replaced' if replaced else 'created'), + 'ids: %s' % ', '.join(resource.get('identifiers') or [])) + + @tool('delete_resource', 'Delete a project resource.', + {'file_name': Annotated[str, 'Resource file name, e.g. background.png']}) + async def delete_resource(args): + try: + await asyncio.to_thread(ctx.cp.delete_resource, args['file_name']) + except CloudPebbleError as e: + return _error(ctx, 'delete_resource', str(e)) + _changed(ctx, 'resources') + return _ok(ctx, 'delete_resource', 'deleted %s' % args['file_name']) + + @tool('read_reference', + "Read one of the skill's own reference guides or templates -- the Alloy " + "guide, the watchapp guide, the API reference, a template .c file. Call it " + "with no name to list what is available. These are NOT project files; use " + "read_file for those.", + {'name': Annotated[str, 'e.g. reference/alloy-guide.md, or blank to list']}) + async def read_reference(args): + name = (args.get('name') or '').strip() + if not name: + return _ok(ctx, 'read_reference', 'index', + 'Available:\n' + '\n'.join(' ' + p for p in _reference_index())) + try: + path = _reference_path(name) + with open(path) as handle: + body = handle.read() + except (ValueError, OSError) as e: + return _error(ctx, 'read_reference', '%s. Call read_reference with no name ' + 'to see what exists.' % e) + if len(body) > MAX_REFERENCE_CHARS: + body = body[:MAX_REFERENCE_CHARS] + '\n...(truncated)' + return _ok(ctx, 'read_reference', os.path.relpath(path, SKILLS_ROOT), body) + + @tool('build', 'Compile the project. Returns the build log; a failed compile is a ' + 'normal result, read the log and fix the code.', {}) + async def build(args): + try: + state, build_id, log = await asyncio.to_thread(ctx.cp.build) + except CloudPebbleError as e: + return _error(ctx, 'build', str(e)) + ok = state == 'succeeded' + if ok: + ctx.last_build_id = build_id + ctx.emit('tool', 'build', {'build_id': build_id, 'state': state, + 'log_url': '/ide/project/%d/build/%d/log' % (ctx.cp.project_id, build_id)}) + ctx.emit('tool', 'tool_result', {'tool': 'build', 'ok': ok, + 'summary': 'build %d %s' % (build_id, state)}) + headers = {'succeeded': 'Build %d succeeded.', + 'failed': 'Build %d FAILED.', + 'unknown': 'Build %d did not report a result in time -- it may still ' + 'be running. Do not assume it failed.'} + header = headers[state] % build_id + return {'content': [{'type': 'text', 'text': header + '\n\n' + _tail(log)}]} + + @tool('install', 'Install the latest successful build into the running emulator.', {}) + async def install(args): + try: + build_id = ctx.last_build_id + if build_id is None: + last = await asyncio.to_thread(ctx.cp.last_build) + if not last or last.get('state') != 3: + return _error(ctx, 'install', 'no successful build to install -- run build first') + build_id = last['id'] + pbw = await asyncio.to_thread(ctx.cp.download_pbw, build_id) + conn = await _connect(ctx) + status = await asyncio.to_thread(conn.install, pbw) + # The watch ships APP_LOG output only after it is asked to, and a + # fresh install is exactly when the model wants to read it. + await asyncio.to_thread(conn.enable_app_logs) + except emu.NoEmulator as e: + return _error(ctx, 'install', str(e)) + except CloudPebbleError as e: + return _error(ctx, 'install', str(e)) + if status != 0: + # A rejection is almost always the emulator, not the code: qemu answers + # BlobDB errors this way and CloudPebble's own UI responds by telling + # the user to reboot it. Say so, because a model told only "rejected" + # concludes its own app is crashing and starts deleting working + # features to bisect -- which is exactly what happened on the first + # real run of this feature. + return _error(ctx, 'install', + 'the emulator rejected the install (status %d). This is an ' + 'emulator problem, NOT your code -- the build is fine. Ask ' + 'the user to reboot the emulator in CloudPebble (Build & Run ' + '-> the reboot button, or close and reopen it) and then say ' + 'continue. Do not change working code over this.' % status) + return _ok(ctx, 'install', 'installed build %d' % build_id) + + @tool('screenshot', 'Take a screenshot of the emulator and look at it. Use this to ' + 'verify layout after every install.', {}) + async def screenshot(args): + try: + conn = await _connect(ctx) + png = await asyncio.to_thread(conn.screenshot) + except emu.NoEmulator as e: + return _error(ctx, 'screenshot', str(e)) + b64 = base64.b64encode(png).decode() + # The user always sees the screenshot in the chat panel, even when the + # model cannot. + ctx.emit('tool', 'tool_result', {'tool': 'screenshot', 'ok': True, + 'summary': 'screenshot', 'image_png_b64': b64}) + if not ctx.model_vision: + # Text-only main model: a separate vision model looks at it and the + # description comes back as text, so the verification step still + # happens. Without one configured, say so plainly rather than let + # the model "verify" an image it never received. + if vision.configured(ctx.vision_config): + try: + described, vision_usage = await asyncio.to_thread( + vision.describe, png, ctx.vision_config) + ctx.add_vision_cost(vision_usage) + except vision.VisionError as e: + logger.warning('vision describe failed: %s', e) + return {'content': [{'type': 'text', 'text': + 'Screenshot captured and shown to the user, but it could not be ' + 'described (%s) and YOU CANNOT SEE IMAGES. Do not claim to have ' + 'checked it.' % e}]} + return {'content': [{'type': 'text', 'text': + 'You cannot see images, so a vision model looked at the screenshot ' + 'for you. Treat this description as what is on screen:\n\n%s' % described}]} + return {'content': [{'type': 'text', 'text': + 'Screenshot captured and shown to the user, but THIS MODEL CANNOT SEE ' + 'IMAGES and no vision model is configured. You did not look at it. Do ' + 'not describe it, do not claim it is centred or correct, and do not ' + 'tick off the visual verification checklist. Say the user needs to ' + 'check it, or reason from the code and from logs() instead.'}]} + return {'content': [{'type': 'image', 'data': b64, 'mimeType': 'image/png'}, + {'type': 'text', 'text': 'Screenshot of the emulator.'}]} + + @tool('press', + "Press a button on the watch, or shake it. This is how you drive an app: " + "menus, scrolling, game input, anything that only happens after a press. " + "Take a screenshot afterwards to see what it did. Watchfaces have no " + "buttons -- shake is their only input.", + {'button': Annotated[str, 'up, select, down, back, or shake'], + 'hold_ms': Annotated[int, 'How long to hold it, default 120. ' + 'Use ~700 for a long press']}) + async def press(args): + button = (args.get('button') or '').strip().lower() + if button not in emu.BUTTONS and button != 'shake': + return _error(ctx, 'press', 'unknown button %r -- use up, select, down, ' + 'back or shake' % button) + try: + conn = await _connect(ctx) + if button == 'shake': + await asyncio.to_thread(conn.tap) + else: + await asyncio.to_thread(conn.press_button, button, + int(args.get('hold_ms') or 120)) + except emu.NoEmulator as e: + return _error(ctx, 'press', str(e)) + return _ok(ctx, 'press', button if button == 'shake' else 'pressed %s' % button) + + @tool('logs', 'Drain app and phone logs from the emulator for a few seconds. ' + 'Covers APP_LOG from the watch and console.log from the phone-side ' + 'JS, which is where AppMessage and web request problems show up.', + {'seconds': Annotated[int, 'How long to collect logs, 1-30']}) + async def logs(args): + seconds = max(1, min(int(args.get('seconds') or 5), 30)) + try: + conn = await _connect(ctx) + # Harmless if the app was installed by the browser rather than by us. + await asyncio.to_thread(conn.enable_app_logs) + lines = await asyncio.to_thread(conn.logs, seconds) + except emu.NoEmulator as e: + return _error(ctx, 'logs', str(e)) + return _ok(ctx, 'logs', '%d log lines' % len(lines), + _tail('\n'.join(lines)) or '(no output in %ds)' % seconds) + + return create_sdk_mcp_server( + name=SERVER_NAME, + tools=[list_files, set_app_settings, read_file, write_file, delete_file, + write_binary_file, write_resource, delete_resource, read_reference, + build, install, press, screenshot, logs], + ) diff --git a/cloudpebble-agent/vision.py b/cloudpebble-agent/vision.py new file mode 100644 index 0000000..7216b16 --- /dev/null +++ b/cloudpebble-agent/vision.py @@ -0,0 +1,132 @@ +"""Describe a screenshot with a vision model, for main models that cannot see. + +Several strong tool-calling models are text-only (DeepSeek's API rejects image +content outright: "unknown variant `image_url`, expected `text`"). Handing them +an image block means they "verify" a screenshot they never received, which is +worse than not looking at all -- the visual check is the whole quality mechanism +here. + +So when the main model has no vision, the screenshot goes to a separate vision +model and its description comes back as text. Configure with: + + AGENT_VISION_API_BASE=https://openrouter.ai/api + AGENT_VISION_API_KEY=... + AGENT_VISION_MODEL=mistralai/mistral-small-3.2-24b-instruct + +Any Anthropic-format /v1/messages endpoint works. Leave unset to disable, in +which case screenshot() tells the model plainly that it cannot see. + +Choose this model on ACCURACY, not price. It costs ~$0.0001 a call against a +turn that costs dollars, so the cheapest option is false economy: a description +that invents content makes the main model rewrite code for a screen it never saw. + +Two ways to get this wrong, both observed: + * A mandatory-reasoning model returns no text at all -- it spends the entire + budget thinking. qwen/qwen3.8-max gave 1500 thinking tokens and zero text at + max_tokens=1500, then 4000 and zero at 4000, and OpenRouter refuses + thinking:{"type":"disabled"} with "Reasoning is mandatory for this endpoint". + openai/gpt-5-nano behaves the same way. + * A small model reads the text correctly and hallucinates the artwork. + google/gemma-3-12b-it reported the time and date accurately, then described a + space scene as "a yellow bird with a red beak" among "hills or mounds of green". + +Verified accurate: mistralai/mistral-small-3.2-24b-instruct (names planet, rocket +and stars correctly), google/gemini-2.5-flash-lite (accurate and free). +""" +import base64 +import json +import logging +import os +import urllib.error +import urllib.request + +logger = logging.getLogger(__name__) + +API_BASE = os.environ.get('AGENT_VISION_API_BASE', '').rstrip('/') +API_KEY = os.environ.get('AGENT_VISION_API_KEY', '') +MODEL = os.environ.get('AGENT_VISION_MODEL', '') +TIMEOUT = int(os.environ.get('AGENT_VISION_TIMEOUT', '120')) +MAX_TOKENS = int(os.environ.get('AGENT_VISION_MAX_TOKENS', '1500')) + +# Written for the caller: a watchface reviewer, not a generic captioner. The +# main model acts on this text, so it has to carry the things the verification +# checklist asks about. +PROMPT = ( + "This is a screenshot of a Pebble smartwatch running a watchface. Describe it " + "precisely and literally, for someone who cannot see it and has to decide " + "whether the layout is correct.\n" + "Cover, in order:\n" + "1. The exact time and any date text shown, verbatim.\n" + "2. Everything drawn on screen and roughly where it sits.\n" + "3. Colours, including the background.\n" + "4. Anything clipped at an edge, overlapping badly, cut off, or unreadable.\n" + "5. Anything that looks broken, blank, or obviously wrong.\n" + "Report only what is actually visible. If the screen shows the stock " + "'Install an app to continue' watch screen, say exactly that -- it means no " + "app is installed. Do not speculate about code." +) + + +def configured(override=None): + """A per-turn describer config wins over the container's own.""" + if override: + return bool(override.get('api_base') and override.get('api_key') and override.get('model')) + return bool(API_BASE and API_KEY and MODEL) + + +def describe(png_bytes, override=None): + """Describe the screenshot. Returns (text, usage). + + usage carries the describer's own cost: on a model that cannot see, every + screenshot is a paid call to another provider, and leaving it out understates + what a run actually costs. + """ + if not configured(override): + raise VisionError('no vision model configured') + base = (override or {}).get('api_base') or API_BASE + key = (override or {}).get('api_key') or API_KEY + model = (override or {}).get('model') or MODEL + + body = json.dumps({ + 'model': model, + 'max_tokens': MAX_TOKENS, + 'messages': [{'role': 'user', 'content': [ + {'type': 'image', 'source': {'type': 'base64', 'media_type': 'image/png', + 'data': base64.b64encode(png_bytes).decode()}}, + {'type': 'text', 'text': PROMPT}, + ]}], + }).encode() + + req = urllib.request.Request( + base + '/v1/messages', data=body, + headers={'Authorization': 'Bearer %s' % key, + 'Content-Type': 'application/json'}) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + payload = json.loads(r.read()) + except urllib.error.HTTPError as e: + detail = e.read()[:300].decode(errors='replace') + raise VisionError('vision model HTTP %s: %s' % (e.code, detail)) + except Exception as e: + raise VisionError('vision model unreachable: %s' % e) + + # Reasoning models spend the budget on thinking blocks and can return no + # text at all; that is a failure, not an empty description. + text = '\n'.join(b.get('text', '') for b in payload.get('content', []) + if b.get('type') == 'text').strip() + if not text: + raise VisionError('vision model returned no text (all reasoning tokens?)') + + u = payload.get('usage') or {} + usage = { + 'model': model, + 'input_tokens': u.get('input_tokens'), + 'output_tokens': u.get('output_tokens'), + # OpenRouter returns the actual charge; other gateways may not. + 'cost_usd': u.get('cost'), + } + return text, usage + + +class VisionError(Exception): + pass diff --git a/cloudpebble/cloudpebble/settings.py b/cloudpebble/cloudpebble/settings.py index cbcbbab..3ddf843 100644 --- a/cloudpebble/cloudpebble/settings.py +++ b/cloudpebble/cloudpebble/settings.py @@ -471,6 +471,42 @@ def _redis_db_url(redis_url, db_index): QEMU_LAUNCH_AUTH_HEADER = _environ.get('QEMU_LAUNCH_AUTH_HEADER', 'secret') QEMU_LAUNCH_TIMEOUT = int(_environ.get('QEMU_LAUNCH_TIMEOUT', 25)) +AGENT_URL = _environ.get('AGENT_URL', '') +# No default: an empty shared secret fails closed on both sides rather than letting a +# guessable one ('secret') authenticate a public, quota-spending endpoint. +# Generate with `openssl rand -hex 32`. +AGENT_AUTH_HEADER = _environ.get('AGENT_AUTH_HEADER', '') +# Feature flag: comma-separated user ids allowed to use the AI agent panel, or '*' for +# everybody. Kept as raw strings -- ide.api.agent._check_enabled stringifies both sides, +# and int() here would blow up at import time on the documented wildcard. +AGENT_ENABLED_USERS = _environ.get('AGENT_ENABLED_USERS', '').replace(',', ' ').split() +AGENT_MAX_TURNS_PER_DAY = int(_environ.get('AGENT_MAX_TURNS_PER_DAY', 50)) +# Gap timeout on the agent VM's event stream. Slow models go quiet for minutes +# mid-generation; 900s cut a real turn off and reported it as a service outage. +AGENT_TURN_READ_TIMEOUT = int(_environ.get('AGENT_TURN_READ_TIMEOUT', 1800)) +# How long a session may sit in 'running' with no new events before it is treated +# as abandoned and healed. See ide.api.agent.heal_if_stale. Must stay above the +# read timeout: below it this measures a model thinking, not a relay dying, and +# at 240s it killed live turns. +AGENT_STALE_TURN_SECONDS = int(_environ.get('AGENT_STALE_TURN_SECONDS', + AGENT_TURN_READ_TIMEOUT + 120)) + +# Free tier: what a user gets before they bring their own provider. Deliberately +# a cheap text-only model plus a vision describer -- a model that cannot see is +# told so and stays honest, where a weak vision model invents what it "saw". +AGENT_FREE_MODEL = _environ.get('AGENT_FREE_MODEL', 'deepseek-v4-flash') +AGENT_FREE_API_BASE = _environ.get('AGENT_FREE_API_BASE', 'https://api.deepseek.com/anthropic') +AGENT_FREE_API_KEY = _environ.get('AGENT_FREE_API_KEY', '') +# Describer, used whenever the acting model has no vision of its own. +AGENT_VISION_API_BASE = _environ.get('AGENT_VISION_API_BASE', 'https://openrouter.ai/api') +AGENT_VISION_API_KEY = _environ.get('AGENT_VISION_API_KEY', '') +AGENT_VISION_MODEL = _environ.get('AGENT_VISION_MODEL', 'mistralai/mistral-small-3.2-24b-instruct') +# Anthropic sign-in. Empty disables it, which is the correct production value: +# Anthropic offers no self-serve OAuth client registration, so the only id that +# works belongs to Claude Code, and the consent screen would name that instead of +# this app. Set it only for local testing. +AGENT_ANTHROPIC_OAUTH_CLIENT_ID = _environ.get('AGENT_ANTHROPIC_OAUTH_CLIENT_ID', '') + PHONE_SHORTURL = _environ.get('PHONE_SHORTURL', 'pbl.zip/sensors') FIREBASE_PROJECT_ID = _environ.get('FIREBASE_PROJECT_ID', 'coreapp-ce061') diff --git a/cloudpebble/docker_start.sh b/cloudpebble/docker_start.sh index e039551..a81ca35 100644 --- a/cloudpebble/docker_start.sh +++ b/cloudpebble/docker_start.sh @@ -23,6 +23,10 @@ if [ ! -z "$RUN_WEB" ]; then $PYTHON manage.py migrate --noinput fi $PYTHON manage.py collectstatic --noinput 2>/dev/null || true + # Agent turns are relayed by threads in this process; nothing survives the + # restart that is about to finish. Release them so their sessions are not + # wedged 'running' until the stale-turn timeout half an hour from now. + $PYTHON manage.py release_orphaned_agent_turns 2>/dev/null || true if [ ! -z "$DEBUG" ]; then $PYTHON manage.py runserver 0.0.0.0:$PORT else diff --git a/cloudpebble/ide/agent_providers.py b/cloudpebble/ide/agent_providers.py new file mode 100644 index 0000000..d30dc2d --- /dev/null +++ b/cloudpebble/ide/agent_providers.py @@ -0,0 +1,139 @@ +"""Decide which model a turn runs on, and with whose quota. + +Four routes, in the order they are offered to the user: + + anthropic their Claude subscription or API key. Agent SDK native, so skills, + subagents and vision all work exactly as designed. Authenticating a + third-party app with a user's Claude plan is supported: + https://support.claude.com/en/articles/15036540 + The secret is either an API key or an OAuth token from + `claude setup-token`; the SDK reads them from different variables. + openrouter their OpenRouter key plus any model string that service serves. + This is also the route to OpenAI models (openai/gpt-5.2) and to + anything else with a published model id. + free our keys, a cheap model, and a vision describer standing in for the + model's missing eyes. This is what a signed-out user gets. + +Signing in with an OpenAI account is deliberately absent. OpenAI serves no +Anthropic-format /v1/messages endpoint -- api.openai.com/anthropic/v1/messages +and /v1/messages both 404 -- so the Claude Agent SDK cannot drive it, and the +Codex OAuth flow that backs OpenClaw belongs to a different harness. OpenAI +models are reachable today through OpenRouter; supporting an OpenAI *subscription* +would mean running a translating gateway or adding a second harness. + +The free tier is deliberately a text-only model plus a describer rather than a +weak vision model: a model that cannot see is told so and behaves honestly, +whereas a model handed an image it cannot read invents a description and then +edits code against it. +""" +from django.conf import settings +from django.utils.translation import gettext as _ + +from ide.models.agent import AgentCredential + +# Sensible defaults per provider. A user may name any model their provider +# serves; these only fill in when they do not. +DEFAULT_MODELS = { + AgentCredential.PROVIDER_ANTHROPIC: 'claude-sonnet-5', + AgentCredential.PROVIDER_OPENROUTER: 'anthropic/claude-sonnet-5', +} + +# Anthropic-format /v1/messages endpoints. Empty means first-party Anthropic. +API_BASES = { + AgentCredential.PROVIDER_ANTHROPIC: '', + AgentCredential.PROVIDER_OPENROUTER: 'https://openrouter.ai/api', +} + +# Providers whose models can read an image directly. Everything else gets the +# describer, which is why the free tier still verifies its own work. +VISION_CAPABLE = { + AgentCredential.PROVIDER_ANTHROPIC: True, + # OpenRouter serves both kinds, and the user picks the model. Assume it can + # see; if it cannot, the SDK errors on the image block rather than silently + # pretending, and they can switch models. + AgentCredential.PROVIDER_OPENROUTER: True, +} + + +def free_tier_config(): + """Our keys, our bill. Everything comes from settings so no secret is here.""" + if not settings.AGENT_FREE_API_KEY: + return None + return { + 'provider': 'free', + 'model': settings.AGENT_FREE_MODEL, + 'api_base': settings.AGENT_FREE_API_BASE, + 'api_key': settings.AGENT_FREE_API_KEY, + 'secret_kind': AgentCredential.KIND_API_KEY, + 'model_vision': False, + 'vision': { + 'api_base': settings.AGENT_VISION_API_BASE, + 'api_key': settings.AGENT_VISION_API_KEY, + 'model': settings.AGENT_VISION_MODEL, + }, + } + + +def config_for(user): + """The provider config for this user's next turn, or None if unavailable. + + Their own credential wins; otherwise the free tier. Returns plaintext + secrets, so the result goes straight into the turn payload and is never + logged, persisted or echoed back to the browser. + """ + credential = AgentCredential.objects.filter(user=user).first() + if credential is None: + return free_tier_config() + + provider = credential.provider + return { + 'provider': provider, + 'model': credential.model or DEFAULT_MODELS.get(provider, ''), + 'api_base': API_BASES.get(provider, ''), + 'api_key': credential.secret(), + 'secret_kind': credential.secret_kind, + 'model_vision': VISION_CAPABLE.get(provider, True), + # A user on their own key still gets the describer configured, so a + # text-only model of their choosing keeps working. + 'vision': { + 'api_base': settings.AGENT_VISION_API_BASE, + 'api_key': settings.AGENT_VISION_API_KEY, + 'model': settings.AGENT_VISION_MODEL, + }, + } + + +def describe_for_ui(user): + """What the settings panel shows. Never includes the secret itself.""" + # Whether the panel can offer browser sign-in for Anthropic, or has to fall + # back to telling the user to run `claude setup-token`. + anthropic_oauth = bool(getattr(settings, 'AGENT_ANTHROPIC_OAUTH_CLIENT_ID', '')) + credential = AgentCredential.objects.filter(user=user).first() + if credential is None: + free = free_tier_config() + return { + 'provider': 'free', + 'configured': False, + 'model': free['model'] if free else None, + 'available': free is not None, + 'description': _("Using the free shared model. Sign in with your own " + "provider for better results and higher limits."), + 'anthropic_oauth': anthropic_oauth, + } + if credential.needs_reauth: + description = _("Your %s credentials stopped working. Enter a new key or " + "token to carry on.") % credential.get_provider_display() + else: + description = _("Using your own %s credentials.") % credential.get_provider_display() + return { + 'provider': credential.provider, + 'configured': True, + 'secret_kind': credential.secret_kind, + 'masked_secret': credential.masked, + 'model': credential.model or DEFAULT_MODELS.get(credential.provider, ''), + 'available': True, + 'anthropic_oauth': anthropic_oauth, + 'needs_reauth': credential.needs_reauth, + 'auth_error': credential.auth_error or '', + 'description': description, + } diff --git a/cloudpebble/ide/api/agent.py b/cloudpebble/ide/api/agent.py new file mode 100644 index 0000000..2c380a4 --- /dev/null +++ b/cloudpebble/ide/api/agent.py @@ -0,0 +1,1157 @@ +import base64 +import hashlib +import json +import logging +import secrets +import re +import threading +import time + +import requests +from django.conf import settings +from django.contrib.auth.decorators import login_required +import uuid as uuid_module + +from django.core.exceptions import (ObjectDoesNotExist, PermissionDenied, + ValidationError) +from django.db import IntegrityError, connection, transaction +from django.db.models import Max +from django.http import HttpResponse, HttpResponseNotFound, StreamingHttpResponse +from django.shortcuts import get_object_or_404, render +from django.urls import reverse +from urllib.parse import urlencode + +from django.utils import timezone +from django.utils.translation import gettext as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_POST + +from ide.agent_providers import config_for, describe_for_ui +from ide.models.agent import (AgentCredential, AgentMessage, AgentSession, + AgentTranscript) +from ide.models.project import Project +from utils.agent_token import agent_token_required, consume_turn, mint, revoke +from utils.jsonview import BadRequest, InternalServerError, json_view +from utils.redis_helper import redis_client + +__author__ = 'ericmigi' + +logger = logging.getLogger(__name__) + +# Browser SSE poll cadence and keepalive, matching ide.api.sse's reasoning: the +# comment line is what makes gunicorn notice a vanished client. +POLL_SECONDS = 0.5 +HEARTBEAT_SECONDS = 15 +# Connect/read timeouts for the agent VM. The read timeout is a gap timeout, not +# a total: requests resets it on every chunk, so it only has to outlast the +# longest silence between SSE events -- but a model thinking through a big +# generation goes quiet for minutes at a stretch. +# +# Measured whole turns on the space-watchface brief: claude-sonnet-5 441s, +# deepseek-v4-flash 912s, kimi-k3 1400s. A 900s read timeout cut deepseek off +# mid-turn and reported it as "service unavailable", so give it real headroom. +TURN_TIMEOUT = (10, getattr(settings, 'AGENT_TURN_READ_TIMEOUT', 1800)) + +# Same shape ide/urls.py accepts for qemu_mobile_token. +EMULATOR_UUID_RE = re.compile(r'^[0-9a-f-]{32,36}$') + +# Error kinds that end a turn. Anything else -- a transcript mirror hiccup, a +# recoverable assistant-level error -- streams through while the query continues. +FATAL_ERROR_KINDS = frozenset(['relay', 'timeout', 'cancelled', 'usage_limit', 'auth']) + +# A turn is relayed by an in-process thread, so a deploy, a worker restart or the +# agent VM bouncing kills it with no terminal event: the session stays 'running' +# forever, every new message is refused with "already working", and the browser +# sits disabled. Sessions therefore heal themselves -- if nothing has been +# appended for this long, the relay is gone. +# +# This measures silence, not death, and healing a LIVE turn is worse than the bug +# it fixes: at 240s a healthy turn that spent five minutes generating a file got a +# "the agent stopped responding" card while its own tool results kept arriving +# underneath it. Past the relay's read timeout, silence really does mean the relay +# is gone -- it would have timed out and written its own terminal event first. +STALE_TURN_SECONDS = getattr(settings, 'AGENT_STALE_TURN_SECONDS', + TURN_TIMEOUT[1] + 120) + + +# --------------------------------------------------------------------------- +# helpers + + +def _agent_url(path): + return settings.AGENT_URL.rstrip('/') + '/' + path + + +def _agent_headers(): + return {'Authorization': settings.AGENT_AUTH_HEADER} + + +def _require_agent_service(): + """Fail closed rather than talking to the agent VM with a guessable secret.""" + if not settings.AGENT_URL or not settings.AGENT_AUTH_HEADER: + raise InternalServerError(_("No agent service is configured.")) + + +def agent_enabled(user): + """Feature flag. Empty setting means nobody; '*' means everybody. + + Settings keeps the raw strings, but tolerate a csv string or a list of ints too so + the flag behaves the same however it is supplied. + """ + if not user.is_authenticated: + return False + enabled = settings.AGENT_ENABLED_USERS + if isinstance(enabled, str): + enabled = enabled.replace(',', ' ').split() + enabled = {str(x).strip() for x in enabled} + return '*' in enabled or str(user.id) in enabled + + +def _check_enabled(user): + if not agent_enabled(user): + raise PermissionDenied(_("The agent is not enabled for your account.")) + + +def _get_project(request, project_id): + _check_enabled(request.user) + return get_object_or_404(Project, pk=project_id, owner=request.user) + + +def _get_session(request, project): + """session_id in the request, or the project's most recent session.""" + session_id = request.POST.get('session_id') or request.GET.get('session_id') + if session_id: + return get_object_or_404(AgentSession, pk=session_id, project=project, user=request.user) + session = project.agent_sessions.filter(user=request.user).order_by('-created').first() + if session is None: + raise BadRequest(_("No agent session; call agent/start first.")) + return session + + +def _stream_key(session_id): + return 'agent-stream-%s' % session_id + + +def _append(session, role, type_, data): + """Persist one event and push it to the browser-facing redis list. + + max(seq)+1 is not atomic and this is not single-writer: the relay thread + appends while /cancel, /message and heal_if_stale all append from the request + path. A collision on the unique (session, seq) surfaces as a ValidationError + from IdeModel's pre_save full_clean, or an IntegrityError if it slips past -- + either one, inside the relay's blanket except, would end a healthy turn as + "the agent service is unavailable". So retry rather than surface it. + """ + content = {'type': type_, 'data': data} + for attempt in range(3): + seq = (AgentMessage.objects.filter(session=session) + .aggregate(Max('seq'))['seq__max'] or 0) + 1 + try: + with transaction.atomic(): + AgentMessage.objects.create(session=session, seq=seq, role=role, + content=content) + break + except (IntegrityError, ValidationError): + if attempt == 2: + raise + logger.info("agent: seq %s collided on session %s, retrying", seq, session.pk) + event = {'seq': seq, 'role': role, 'type': type_, 'data': data} + key = _stream_key(session.id) + redis_client.rpush(key, json.dumps(event)) + redis_client.expire(key, 3600) + # Keeps heal_if_stale accurate through a long turn without a session .save() + # on the hot path (auto_now would fight the relay's own writes). + AgentSession.objects.filter(pk=session.pk).update(last_active=timezone.now()) + return event + + +def _flag_credential(user_id, message): + """Remember that this user's key stopped working, so the panel can say so.""" + try: + AgentCredential.objects.filter(user_id=user_id).update( + auth_failed_at=timezone.now(), auth_error=(message or '')[:500]) + except Exception: + logger.exception("agent: could not flag credential for user %s", user_id) + + +def _clear_credential_flag(user_id): + """A turn completed, so whatever was wrong with the credential is not.""" + try: + AgentCredential.objects.filter( + user_id=user_id, auth_failed_at__isnull=False).update( + auth_failed_at=None, auth_error='') + except Exception: + logger.exception("agent: could not clear credential flag for user %s", user_id) + + +def _sse(event): + return 'data: %s\n\n' % json.dumps(event) + + +def _last_activity(session): + """When this session last produced anything. Messages are the real signal; + last_active only moves when the session row itself is saved.""" + latest = AgentMessage.objects.filter(session=session).aggregate(Max('created'))['created__max'] + if latest and session.last_active: + return max(latest, session.last_active) + return latest or session.last_active + + +def _stop_turn(session, status): + """Tell the agent VM to stop, and retire this turn's number. + + Both the ways a turn ends early -- the user pressing Stop, and a wedged + session being healed -- have to do this. Healing used to only flip the status, + so the VM carried on working and the freed session accepted a new message: + two turns then wrote into the same project at once. + """ + if settings.AGENT_URL and settings.AGENT_AUTH_HEADER: + try: + # Keyed on the AgentSession pk, the same value /turn registered. + requests.post(_agent_url('cancel'), json={'session_id': session.id}, + headers=_agent_headers(), timeout=10) + except requests.RequestException as e: + logger.warning("agent: cancel failed for session %s: %s", session.id, e) + session.status = status + # Retire the turn number too. The agent VM takes a moment to wind down, and + # its relay must not append to the transcript or reset the status after the + # user has moved on -- especially when they stopped in order to say something + # else, which starts the next turn immediately. + session.turn_count += 1 + session.save(update_fields=['status', 'turn_count']) + + +def heal_if_stale(session): + """Release a session wedged in 'running' by a relay that died. + + Emits a terminal error so an already-open browser re-enables its composer, + rather than only unsticking the next caller. Returns True if it healed. + """ + if session.status != 'running': + return False + last = _last_activity(session) + if last and (timezone.now() - last).total_seconds() < STALE_TURN_SECONDS: + return False + logger.warning("agent: healing stale session %s (last activity %s)", session.id, last) + _stop_turn(session, 'error') + _append(session, 'system', 'error', { + 'message': _("The agent stopped responding and the turn was ended. Try again."), + 'kind': 'timeout', + }) + return True + + +# --------------------------------------------------------------------------- +# the relay + + +def _iter_sse(response): + """Yield parsed JSON payloads from an SSE response body.""" + for line in response.iter_lines(decode_unicode=True): + if not line or not line.startswith('data:'): + continue + payload = line[len('data:'):].strip() + if not payload: + continue + try: + yield json.loads(payload) + except ValueError: + logger.warning("agent: unparseable SSE payload: %r", payload[:200]) + + +# The controller reaps an emulator 300s after its last ping, so ping well inside +# that -- a missed one must not be fatal. +EMULATOR_PING_SECONDS = getattr(settings, 'AGENT_EMULATOR_PING_SECONDS', 60) + + +def _keepalive(uuid, stop): + """Hold the user's emulator open for as long as the turn runs. + + The only thing pinging the qemu controller is the browser tab, and a phone + freezes that tab's timers the moment the screen locks. Turns run for minutes, + so the watch the agent is about to install to gets reaped out from under it: + the tool then reports "emulator never came up after 20 attempts", which reads + like the emulator failed to start rather than like it was killed for being + idle while the agent was busy. + + Pings every configured qemu server: the emulator lives on exactly one of + them, and the others answer alive=False without side effects. + """ + while not stop.wait(EMULATOR_PING_SECONDS): + for server in settings.QEMU_URLS: + try: + requests.post('%sqemu/%s/ping' % (server, uuid), timeout=5, verify=False) + except requests.RequestException as e: + logger.debug("agent: emulator ping to %s failed: %s", server, e) + + +def _superseded(session_id, turn): + """True once a newer turn has started on this session. + + Stopping and immediately sending a new instruction leaves the old relay + draining a stream the user has already walked away from. Without this check + its late events land in the new turn's transcript, and its terminal event + flips the session to idle underneath a turn that is still running -- + re-enabling the composer and letting a third turn start concurrently. + """ + if turn is None: + return False + return not AgentSession.objects.filter(pk=session_id, turn_count=turn).exists() + + +def _relay(session_id, token, message, emulator, cp_base_url, provider=None, turn=None): + """Consume the agent VM's SSE stream into AgentMessage rows + redis. + + Runs off-request. Under gunicorn's gevent worker threading is monkeypatched, + so this is a greenlet; under `manage.py runserver` it is a real thread. Both + work, which is why this is threading and not gevent.spawn. + + ponytail: an in-process thread dies with the web worker, leaving the session + 'running' with no terminal event, and the browser stream then just sits there. + Move to a celery task on a dedicated queue if turns need to survive deploys. + """ + terminal = False + stop_keepalive = threading.Event() + if emulator and emulator.get('uuid') and settings.QEMU_URLS: + threading.Thread(target=_keepalive, name='agent-keepalive-%s' % session_id, + daemon=True, args=(emulator['uuid'], stop_keepalive)).start() + try: + session = AgentSession.objects.get(pk=session_id) + payload = { + # The stable AgentSession pk: what /cancel keys on, and always truthy. + 'session_id': session.id, + # The SDK's own id, for resume. None on the first turn. + 'sdk_session_id': session.sdk_session_id or None, + 'project_id': session.project_id, + 'cp_token': token, + 'cp_base_url': cp_base_url, + 'emulator': emulator, + 'message': message, + # Whose quota this turn spends. Contains a plaintext secret, so it is + # never logged and never echoed back to the browser. + 'provider': provider, + } + response = requests.post(_agent_url('turn'), json=payload, headers=_agent_headers(), + stream=True, timeout=TURN_TIMEOUT) + response.raise_for_status() + for event in _iter_sse(response): + if _superseded(session_id, turn): + logger.info("agent: dropping turn %s of session %s, a newer one started", + turn, session_id) + terminal = True # the newer turn owns the session's status now + break + type_ = event.get('type', 'text') + role = event.get('role', 'assistant') + data = event.get('data') or {} + # The agent tells us its SDK session id so the next turn can resume. + sdk_session_id = data.get('sdk_session_id') + if sdk_session_id and session.sdk_session_id != sdk_session_id: + session.sdk_session_id = sdk_session_id + session.save(update_fields=['sdk_session_id']) + if type_ == 'error' and data.get('kind') == 'auth': + _flag_credential(session.user_id, data.get('message', '')) + elif type_ == 'done': + _clear_credential_flag(session.user_id) + _append(session, role, type_, data) + # A 'mirror_error' or an assistant-level error is not the end of the turn: + # the agent explicitly carries on after both. + if type_ == 'done' or (type_ == 'error' and data.get('kind') in FATAL_ERROR_KINDS): + terminal = True + session.status = 'idle' if type_ == 'done' else 'error' + # update_fields, because this object was read when the turn began: + # a full save writes back its stale turn_count and undoes the + # increment a newer turn made, which silently unfences that turn. + session.save(update_fields=['status']) + break + except Exception as exc: + logger.exception("agent: relay failed for session %s", session_id) + # Deliberately not str(exc): requests puts the private agent host in the + # message, and this string is persisted and shown to the user. + if isinstance(exc, requests.exceptions.ReadTimeout): + # The turn may well still be running on the agent VM; we just stopped + # listening. Say that rather than blaming the service. + message_text = _("The agent went quiet for too long and the turn was " + "ended. It may still have been working.") + else: + message_text = _("The agent service is unavailable.") + if _superseded(session_id, turn): + # A stopped turn's connection dying is not news the user needs, and + # the session's status belongs to the turn that replaced it. + terminal = True + else: + try: + session = AgentSession.objects.get(pk=session_id) + session.status = 'error' + session.save(update_fields=['status']) + _append(session, 'system', 'error', {'message': message_text, 'kind': 'relay'}) + terminal = True + except Exception: + logger.exception("agent: could not record relay failure for session %s", + session_id) + finally: + # The turn is over: stop holding the emulator open, and let the browser's + # own ping decide its fate from here. + stop_keepalive.set() + # The token's work is done -- don't leave it live in redis for the rest + # of its 30 minute TTL. + revoke(token) + if not terminal and not _superseded(session_id, turn): + # Never leave a browser stream hanging -- unless a newer turn owns the + # session now, in which case its 'done' is not ours to write. + try: + session = AgentSession.objects.get(pk=session_id) + if session.status == 'running': + session.status = 'idle' + session.save(update_fields=['status']) + _append(session, 'system', 'done', {'turn_count': session.turn_count}) + except Exception: + logger.exception("agent: could not close out session %s", session_id) + connection.close() + + +# --------------------------------------------------------------------------- +# browser-facing views + + +@login_required +@require_POST +@json_view +def agent_start(request, project_id): + """The project's chat session, created on first use. + + Get-or-create rather than always-create: the panel calls this on every page load, + and reusing the session is what makes the conversation (and the SDK's resume id) + survive a reload. + """ + project = _get_project(request, project_id) + session = project.agent_sessions.filter(user=request.user).order_by('-created').first() + if session is None: + session = AgentSession.objects.create(project=project, user=request.user, status='idle') + else: + heal_if_stale(session) + return {'session_id': session.id, 'status': session.status} + + +def _emulator_spec(raw): + """Reduce the browser's emulator blob to a descriptor the agent VM may dial. + + The websocket URL is built here from QEMU_PUBLIC_URL, never taken from the request: + the agent VM connects to whatever it is handed, so a caller-chosen host would be an + SSRF primitive aimed at the loop box's own network. + """ + if not raw: + return None + try: + spec = json.loads(raw) + except ValueError: + raise BadRequest(_("Malformed emulator parameter.")) + if not isinstance(spec, dict): + raise BadRequest(_("Malformed emulator parameter.")) + uuid = str(spec.get('uuid') or '') + token = str(spec.get('token') or '') + if not EMULATOR_UUID_RE.match(uuid): + raise BadRequest(_("Malformed emulator parameter.")) + if not 0 < len(token) <= 128 or not token.isprintable() or any(c.isspace() for c in token): + raise BadRequest(_("Malformed emulator parameter.")) + if not settings.QEMU_PUBLIC_URL: + # Nothing off-box can address the emulator; the turn is build-only. + return None + base = settings.QEMU_PUBLIC_URL.rstrip('/').replace('https://', 'wss://').replace('http://', 'ws://') + # Which watch is on screen, so the agent lays out for the size it will be + # screenshotting. Anything unrecognised is dropped rather than passed on: + # this string ends up in the model's context, and a bogus one would have it + # designing for a watch that does not exist. + platform = str(spec.get('platform') or '') + return {'uuid': uuid, 'token': token, + 'platform': platform if platform in _VALID_PLATFORMS else '', + 'ws_url': '%s/qemu/%s/ws/phone' % (base, uuid)} + + +@login_required +@require_POST +@json_view +def agent_message(request, project_id): + project = _get_project(request, project_id) + _require_agent_service() + session = _get_session(request, project) + heal_if_stale(session) + if session.status == 'running': + raise BadRequest(_("The agent is already working. Cancel first.")) + message = request.POST.get('message', '').strip() + if not message: + raise BadRequest(_("Empty message.")) + # Optional -- build-only turns work fine without an emulator. + emulator = _emulator_spec(request.POST.get('emulator')) + + # Everything that can refuse the turn goes first. Marking the session running + # and spending a daily turn before checking the configuration left the user + # with the right error message AND a locked composer AND one fewer turn. + # + # Not request.build_absolute_uri: ALLOWED_HOSTS is '*', and this is the origin + # the agent VM sends the scoped token to. + if not settings.PUBLIC_URL: + raise InternalServerError(_("PUBLIC_URL is not configured.")) + cp_base_url = settings.PUBLIC_URL.rstrip('/') + '/' + + # Their own provider if they have one, otherwise the free tier. Refusing here + # beats letting the turn fail on the agent VM with a less obvious message. + provider = config_for(request.user) + if provider is None: + raise InternalServerError( + _("No model is configured. Add your own provider key in the agent settings.")) + + if not consume_turn(request.user): + raise BadRequest(_("You have reached your daily agent limit. Try again tomorrow.")) + token = mint(request.user, project, session) + + session.status = 'running' + session.turn_count += 1 + session.save() + _append(session, 'user', 'text', {'text': message}) + + threading.Thread(target=_relay, name='agent-relay-%s' % session.id, daemon=True, + args=(session.id, token, message, emulator, cp_base_url, provider, + session.turn_count)).start() + return {'ok': True} + + +def _event_stream(session_id, since): + last_seq = since + key = _stream_key(session_id) + try: + # Catch-up straight from the durable rows, then hand over to redis and drop + # the DB connection — an idle IDE tab must not pin a pooler client (see + # the same reasoning in ide.api.sse). + for msg in AgentMessage.objects.filter(session_id=session_id, seq__gt=last_seq).order_by('seq'): + last_seq = msg.seq + content = msg.content or {} + event = {'seq': msg.seq, 'role': msg.role, + 'type': content.get('type', 'text'), 'data': content.get('data') or {}} + yield _sse(event) + + # The truth about whether a turn is running, sent after the replay and + # never persisted. Replaying history means replaying old terminal events: + # a fatal error from an earlier turn would otherwise leave a reloaded tab + # convinced nothing is running, with no spinner and no Stop button, while + # the agent works away and every message it sends comes back "the agent is + # already working". + session = AgentSession.objects.filter(pk=session_id).first() + if session is not None: + heal_if_stale(session) + yield _sse({'type': 'sync', 'role': 'system', + 'data': {'status': session.status}}) + connection.close() + + # One long-lived stream per tab, kept open across turns and heartbeated -- the + # same posture as ide.api.sse.project_events. Terminal events are streamed and + # then we keep listening, so the next turn flows down this same connection. + # ponytail: a 0.5s LRANGE poll per open tab. Move to redis pubsub (as sse.py + # does) if the tab count ever matters. + index = 0 + since_heartbeat = 0.0 + while True: + # The list carries a 1 hour TTL, so a tab left open through a quiet + # hour comes back to an empty key and a cursor pointing past its end: + # every event of the next turn would be skipped, and the panel would + # sit blank until a reload. seq dedupe makes re-reading harmless. + if redis_client.llen(key) < index: + index = 0 + batch = redis_client.lrange(key, index, -1) + index += len(batch) + if not batch: + time.sleep(POLL_SECONDS) + since_heartbeat += POLL_SECONDS + if since_heartbeat >= HEARTBEAT_SECONDS: + since_heartbeat = 0.0 + # The write is what makes gunicorn notice a vanished client. + yield ': keepalive\n\n' + continue + since_heartbeat = 0.0 + for raw in batch: + event = json.loads(raw) + if event.get('seq', 0) <= last_seq: + continue + last_seq = event['seq'] + yield _sse(event) + except GeneratorExit: + pass + finally: + connection.close() + + +@login_required +@require_GET +def agent_stream(request, project_id): + try: + project = _get_project(request, project_id) + session = _get_session(request, project) + except PermissionDenied: + return HttpResponse(status=403) + except BadRequest as e: + return HttpResponse(str(e), status=400) + # A browser reconnecting to a session whose relay died gets the terminal + # event it never received, instead of hanging on "Working...". + heal_if_stale(session) + try: + since = int(request.GET.get('since', 0)) + except ValueError: + since = 0 + + response = StreamingHttpResponse(_event_stream(session.id, since), + content_type='text/event-stream') + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' + return response + + +@login_required +@require_POST +@json_view +def agent_cancel(request, project_id): + project = _get_project(request, project_id) + session = _get_session(request, project) + _stop_turn(session, 'cancelled') + _append(session, 'system', 'error', {'message': _("Stopped."), 'kind': 'cancelled'}) + return {'ok': True} + + +# --------------------------------------------------------------------------- +# Model credentials — the user's own provider, or the free tier. +# +# OpenRouter supports OAuth PKCE, so a user connects by clicking a link rather +# than finding and pasting a key: https://openrouter.ai/docs/guides/overview/auth/oauth +# +# Anthropic has no equivalent we can use. Its OAuth flow is bound to Claude Code +# and Claude.ai, there is no way to register a client_id, and borrowing Claude +# Code's would misrepresent this app to the user's account. `claude setup-token` +# and paste stays the honest route there. + +# Anthropic PKCE. OFF unless AGENT_ANTHROPIC_OAUTH_CLIENT_ID is set, because the +# only client_id that works here is Claude Code's own: Anthropic has no self-serve +# client registration, so a third-party app cannot obtain one. Borrowing it means +# the consent screen says Claude Code when the app asking is CloudPebble. +# +# That is a deliberate testing shortcut, not a shipping design. Leave the setting +# empty in production and use `claude setup-token` and paste, which the panel +# already walks through. Getting a real client_id means asking Anthropic +# (mcp-review@anthropic.com). +ANTHROPIC_AUTH_URL = 'https://claude.com/cai/oauth/authorize' +ANTHROPIC_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token' +# The redirect shows the code on screen for the user to copy, which is what makes +# this work on a phone with no CLI. +ANTHROPIC_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback' +ANTHROPIC_SCOPE = 'user:inference' +ANTHROPIC_PKCE_SESSION_KEY = 'agent_anthropic_verifier' +ANTHROPIC_STATE_SESSION_KEY = 'agent_anthropic_state' + +OPENROUTER_AUTH_URL = 'https://openrouter.ai/auth' +OPENROUTER_KEY_EXCHANGE = 'https://openrouter.ai/api/v1/auth/keys' +# The verifier must survive the round trip to OpenRouter and back, but must never +# reach the browser, so it lives in the user's server-side session. +PKCE_SESSION_KEY = 'agent_openrouter_verifier' + + +def _pkce_pair(): + verifier = secrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode()).digest() + challenge = base64.urlsafe_b64encode(digest).decode().rstrip('=') + return verifier, challenge + + +@require_POST +@login_required +@json_view +def agent_openrouter_start(request): + """Hand back the URL to send the user to, and remember the verifier.""" + _check_enabled(request.user) + if not settings.PUBLIC_URL: + raise InternalServerError(_("PUBLIC_URL is not configured.")) + verifier, challenge = _pkce_pair() + request.session[PKCE_SESSION_KEY] = verifier + callback = settings.PUBLIC_URL.rstrip('/') + reverse('ide:agent_openrouter_callback') + url = '%s?%s' % (OPENROUTER_AUTH_URL, urlencode({ + 'callback_url': callback, + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + })) + return {'url': url} + + +@require_POST +@login_required +@json_view +def agent_anthropic_start(request): + """URL for the user to open. They come back with a code to paste.""" + _check_enabled(request.user) + client_id = settings.AGENT_ANTHROPIC_OAUTH_CLIENT_ID + if not client_id: + raise BadRequest(_("Anthropic sign-in is not enabled here. Use " + "`claude setup-token` and paste the token instead.")) + verifier, challenge = _pkce_pair() + # State must be its own random value of the same shape as the challenge -- + # a truncated copy of the challenge is rejected as an invalid request. + state = secrets.token_urlsafe(32) + request.session[ANTHROPIC_PKCE_SESSION_KEY] = verifier + request.session[ANTHROPIC_STATE_SESSION_KEY] = state + url = '%s?%s' % (ANTHROPIC_AUTH_URL, urlencode({ + 'code': 'true', + 'client_id': client_id, + 'response_type': 'code', + 'redirect_uri': ANTHROPIC_REDIRECT_URI, + 'scope': ANTHROPIC_SCOPE, + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + # The page shows "#", and the state it echoes back must be + # the one we sent. + 'state': state, + })) + return {'url': url} + + +@require_POST +@login_required +@json_view +def agent_anthropic_finish(request): + """Exchange the pasted code for a token and store it.""" + _check_enabled(request.user) + client_id = settings.AGENT_ANTHROPIC_OAUTH_CLIENT_ID + if not client_id: + raise BadRequest(_("Anthropic sign-in is not enabled here.")) + verifier = request.session.pop(ANTHROPIC_PKCE_SESSION_KEY, None) + expected_state = request.session.pop(ANTHROPIC_STATE_SESSION_KEY, None) + if not verifier: + raise BadRequest(_("That sign-in attempt expired. Start again.")) + # The callback page shows "code#state"; accept either form. + pasted = request.POST.get('code', '').strip() + code, _sep, returned_state = pasted.partition('#') + code = code.strip() + if not code: + raise BadRequest(_("Paste the code from the Anthropic page.")) + if returned_state and expected_state and returned_state.strip() != expected_state: + raise BadRequest(_("That code came from a different sign-in attempt. " + "Start again.")) + payload = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': client_id, + 'redirect_uri': ANTHROPIC_REDIRECT_URI, + 'code_verifier': verifier, + } + # The authorize step round-trips state, and the exchange is rejected without + # it. Prefer what the page echoed back over what we stored, so a paste that + # includes it still works if the session was recycled. + if returned_state or expected_state: + payload['state'] = (returned_state or expected_state or '').strip() + + try: + response = requests.post(ANTHROPIC_TOKEN_URL, json=payload, timeout=30) + except requests.RequestException as e: + logger.warning("agent: anthropic token exchange unreachable: %s", e) + raise BadRequest(_("Could not reach Anthropic to finish signing in.")) + + if response.status_code >= 400: + # Surface what the provider actually said: "may have expired" sent the + # user round the loop again when the real problem was the request. + detail = response.text[:300] + logger.warning("agent: anthropic token exchange %s: %s", + response.status_code, detail) + raise BadRequest(_("Anthropic rejected the sign-in (%(status)s): %(detail)s") + % {'status': response.status_code, 'detail': detail}) + + token = (response.json() or {}).get('access_token') + if not token: + raise BadRequest(_("Anthropic did not return a token.")) + + credential = AgentCredential.objects.filter(user=request.user).first() + if credential is None: + credential = AgentCredential(user=request.user) + credential.provider = AgentCredential.PROVIDER_ANTHROPIC + credential.secret_kind = AgentCredential.KIND_OAUTH + credential.model = request.POST.get('model', '').strip()[:128] + credential.set_secret(token) + credential.save() + return describe_for_ui(request.user) + + +@login_required +def agent_openrouter_callback(request): + """Where OpenRouter sends the user back, with ?code=... + + Exchanges the code for a key server-side and stores it, so the key never + touches the browser. Renders a tiny page that closes itself. + """ + code = request.GET.get('code', '') + verifier = request.session.pop(PKCE_SESSION_KEY, None) + error = None + if not code: + error = _("OpenRouter did not return an authorisation code.") + elif not verifier: + error = _("This authorisation link has expired. Start again.") + else: + try: + response = requests.post(OPENROUTER_KEY_EXCHANGE, json={ + 'code': code, + 'code_verifier': verifier, + 'code_challenge_method': 'S256', + }, timeout=30) + response.raise_for_status() + key = (response.json() or {}).get('key') + if not key: + error = _("OpenRouter did not return a key.") + except requests.RequestException: + logger.exception("agent: OpenRouter key exchange failed") + error = _("Could not reach OpenRouter to finish signing in.") + + if error is None: + credential = AgentCredential.objects.filter(user=request.user).first() + if credential is None: + credential = AgentCredential(user=request.user) + credential.provider = AgentCredential.PROVIDER_OPENROUTER + credential.secret_kind = AgentCredential.KIND_API_KEY + credential.set_secret(key) + credential.save() + + return render(request, 'ide/agent-oauth-done.html', {'error': error}) + + +@require_GET +@login_required +@json_view +def agent_credentials(request): + """What the user is currently running on. Never returns the secret.""" + _check_enabled(request.user) + return describe_for_ui(request.user) + + +@require_POST +@login_required +@json_view +def agent_credentials_save(request): + _check_enabled(request.user) + provider = request.POST.get('provider', '').strip() + valid = dict(AgentCredential.PROVIDER_CHOICES) + if provider not in valid: + raise BadRequest(_("Unknown provider: %s") % provider) + + secret = request.POST.get('secret', '').strip() + + kind = request.POST.get('secret_kind', AgentCredential.KIND_API_KEY) + if kind not in dict(AgentCredential.KIND_CHOICES): + raise BadRequest(_("Unknown credential type.")) + # An OAuth token only means anything to the Anthropic path. + if kind == AgentCredential.KIND_OAUTH and provider != AgentCredential.PROVIDER_ANTHROPIC: + raise BadRequest(_("OAuth tokens are only supported for Anthropic.")) + + # Not get_or_create: IdeModel validates on save, and creating a blank row + # first fails on the empty provider and secret before they can be filled in. + credential = AgentCredential.objects.filter(user=request.user).first() + + # Changing only the model must not cost the user their key: the secret is + # write-only, so the browser cannot send it back, and a blank one here means + # "keep what is stored". It can only be kept when it still applies -- a + # different provider, or an API key where an OAuth token is now wanted, has + # to be re-entered. + keeping = (secret == '' and credential is not None + and credential.provider == provider and credential.secret_kind == kind) + if not secret and not keeping: + raise BadRequest(_("A key or token is required.")) + + if credential is None: + credential = AgentCredential(user=request.user) + credential.provider = provider + credential.secret_kind = kind + credential.model = request.POST.get('model', '').strip()[:128] + if secret: + credential.set_secret(secret) + credential.save() + return describe_for_ui(request.user) + + +@require_POST +@login_required +@json_view +def agent_credentials_delete(request): + """Drop back to the free tier.""" + _check_enabled(request.user) + AgentCredential.objects.filter(user=request.user).delete() + return describe_for_ui(request.user) + + +# --------------------------------------------------------------------------- +# Project settings — agent token only. +# +# The agent cannot reach save_project_settings, which resolves its project from +# the URL against request.user and would let a token touch any project the user +# owns. This endpoint always writes to request.agent_project — the single +# project the token was minted for — and nothing here can reference another +# project or anything outside it. +# +# Deliberately NOT exposed, because they reach beyond this project: +# interdependencies (link to the user's OTHER projects) +# github repo settings, publishing (write to third-party services) +# owner (ownership transfer) +# +# npm dependencies ARE exposed: they name a package on the public registry, not +# another project, and a watchface that needs @moddable/pebbleproxy cannot be +# built without one. +# +# Partial update: only the fields present in the POST are touched. + +_VALID_PLATFORMS = ('aplite', 'basalt', 'chalk', 'diorite', 'emery', 'gabbro', 'flint') +_EMBEDDED_JS_PLATFORMS = {'emery', 'gabbro', 'flint'} + +_BOOL_SETTINGS = ( + 'app_is_watchface', + 'app_is_hidden', + 'app_is_shown_on_communication', + 'app_modern_multi_js', +) +_TEXT_SETTINGS = ( + 'name', + 'app_company_name', + 'app_short_name', + 'app_long_name', + 'app_version_label', + 'app_capabilities', +) + + +def _as_bool(raw): + return str(raw).strip().lower() in ('1', 'true', 'yes', 'on') + + +@csrf_exempt +@require_GET +@agent_token_required +@json_view +def agent_emulator(request, project_id): + """The user's emulator as it is right now -- agent token only. + + A turn is handed one emulator descriptor when it starts and nothing updates it, + so rebooting the emulator mid-turn (which CloudPebble itself tells the user to + do when an install is rejected) left every later install and screenshot dialling + a dead instance for the rest of the turn: "emulator never came up after 20 + attempts", with no way back. The agent calls this to pick up the live one. + + Same shape and the same rules as _emulator_spec: the websocket URL is built + here, never taken from the caller. + """ + preferred = request.GET.get('platform', '') + order = ([preferred] if preferred in _VALID_PLATFORMS else []) + \ + [p for p in _VALID_PLATFORMS if p != preferred] + for platform in order: + raw = redis_client.get('qemu-user-%s-%s' % (request.user.id, platform)) + if not raw: + continue + try: + info = json.loads(raw) + except ValueError: + continue + uuid, token = info.get('uuid'), info.get('token') + if not uuid or not token or not EMULATOR_UUID_RE.match(str(uuid)): + continue + # Registered is not running: the controller reaps, and the row outlives it. + try: + alive = requests.post(info['ping_url'], timeout=3, + verify=False).json().get('alive') + except (requests.RequestException, ValueError, KeyError): + continue + if not alive: + continue + base = settings.QEMU_PUBLIC_URL.rstrip('/').replace( + 'https://', 'wss://').replace('http://', 'ws://') + return {'emulator': {'uuid': uuid, 'token': token, 'platform': platform, + 'ws_url': '%s/qemu/%s/ws/phone' % (base, uuid)}} + return {'emulator': None} + + +@csrf_exempt +@require_POST +@agent_token_required +@json_view +def agent_app_settings(request, project_id): + """Write project settings for the token's project. Never any other project.""" + project = request.agent_project + changed = {} + + for field in _TEXT_SETTINGS: + if field in request.POST: + value = request.POST[field].strip() + if field == 'name' and not value: + raise BadRequest(_("Project name cannot be empty.")) + setattr(project, field, value) + changed[field] = value + + for field in _BOOL_SETTINGS: + if field in request.POST: + value = _as_bool(request.POST[field]) + setattr(project, field, value) + changed[field] = value + + if 'app_platforms' in request.POST: + raw = request.POST['app_platforms'].strip() + if raw: + names = [p.strip() for p in raw.split(',') if p.strip()] + bad = [p for p in names if p not in _VALID_PLATFORMS] + if bad: + raise BadRequest(_("Unknown platform(s): %s") % ', '.join(bad)) + # Same rule save_project_settings enforces. + if project.has_embeddedjs_files: + unsupported = set(names) - _EMBEDDED_JS_PLATFORMS + if unsupported: + raise BadRequest( + _("Projects with Embedded JS files can only target Emery, Gabbro " + "and Flint. Remove: %s") % ', '.join(sorted(unsupported))) + project.app_platforms = ','.join(names) + else: + project.app_platforms = None + changed['app_platforms'] = project.app_platforms + + if 'app_keys' in request.POST: + raw = request.POST['app_keys'].strip() or '[]' + try: + parsed = json.loads(raw) + except ValueError: + raise BadRequest(_("app_keys must be valid JSON.")) + if not isinstance(parsed, (list, dict)): + raise BadRequest(_("app_keys must be a JSON list or object.")) + project.app_keys = json.dumps(parsed) + changed['app_keys'] = project.app_keys + + if 'app_uuid' in request.POST: + value = request.POST['app_uuid'].strip() + try: + uuid_module.UUID(value) + except (ValueError, AttributeError, TypeError): + raise BadRequest(_("app_uuid must be a valid UUID.")) + project.app_uuid = value + changed['app_uuid'] = value + + # Resource ids are looked up through project.resources, so a menu icon + # belonging to another project cannot be selected. + if 'menu_icon' in request.POST: + raw = request.POST['menu_icon'].strip() + old_icon = project.menu_icon + if raw: + try: + icon = project.resources.get(pk=int(raw)) + except (ValueError, ObjectDoesNotExist): + raise BadRequest(_("No such resource in this project: %s") % raw) + if old_icon is not None and old_icon.pk != icon.pk: + old_icon.is_menu_icon = False + old_icon.save() + icon.is_menu_icon = True + icon.save() + changed['menu_icon'] = icon.pk + elif old_icon is not None: + old_icon.is_menu_icon = False + old_icon.save() + changed['menu_icon'] = None + + # npm-style packages only. save_project_dependencies also writes + # interdependencies, which link to the user's OTHER projects -- that half + # stays out of reach, so this is written here rather than by calling it. + if 'app_dependencies' in request.POST: + raw = request.POST['app_dependencies'].strip() or '{}' + try: + parsed = json.loads(raw) + except ValueError: + raise BadRequest(_("app_dependencies must be valid JSON.")) + if not isinstance(parsed, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in parsed.items()): + raise BadRequest(_("app_dependencies must be a JSON object of name -> version.")) + try: + project.set_dependencies(parsed) + except (IntegrityError, ValueError) as e: + raise BadRequest(str(e)) + changed['app_dependencies'] = parsed + + if not changed: + raise BadRequest(_("Nothing to change.")) + + try: + project.save() + except IntegrityError as e: + raise BadRequest(str(e)) + return {'ok': True, 'changed': changed} + + +# --------------------------------------------------------------------------- +# SessionStore mirror — agent token only, no session cookie, no CSRF. +# +# Wire format, matching cloudpebble-agent/session_store.py: +# POST {project_key, subpath, entries: [...]} -> 204 +# GET ?subpath=... -> {entries: [...]} | 404 +# Stored as JSONL, one json.dumps(entry) per line, appended. + + +@csrf_exempt +@agent_token_required +def agent_transcript(request, sdk_session_id): + if request.method == 'POST': + return _transcript_post(request, sdk_session_id) + if request.method == 'GET': + return _transcript_get(request, sdk_session_id) + return HttpResponse(status=405) + + +def _find_transcript(request, sdk_session_id, subpath): + """An existing transcript is only visible to the session it belongs to — the + sdk_session_id is caller-supplied and is not itself a secret.""" + transcript = AgentTranscript.objects.filter(sdk_session_id=sdk_session_id, + subpath=subpath).first() + if transcript is None: + return None + if transcript.session_id != request.agent_session.id: + raise PermissionDenied(_("Agent token is not valid for this transcript.")) + return transcript + + +def _transcript_get(request, sdk_session_id): + subpath = request.GET.get('subpath', '')[:128] + transcript = _find_transcript(request, sdk_session_id, subpath) + if transcript is None: + return HttpResponseNotFound() + entries = [] + for line in bytes(transcript.data or b'').splitlines(): + if not line.strip(): + continue + try: + entries.append(json.loads(line)) + except ValueError: + logger.warning("agent: unparseable transcript line for %s", sdk_session_id) + return HttpResponse(json.dumps({'entries': entries}), content_type='application/json') + + +def _transcript_post(request, sdk_session_id): + try: + body = json.loads(request.body) + entries = body['entries'] + subpath = str(body.get('subpath') or '')[:128] + except (ValueError, KeyError, TypeError): + return HttpResponse('expected {entries: [...]}', status=400) + if not isinstance(entries, list): + return HttpResponse('expected {entries: [...]}', status=400) + + transcript = _find_transcript(request, sdk_session_id, subpath) + if transcript is None: + session = request.agent_session + transcript = AgentTranscript(sdk_session_id=sdk_session_id, subpath=subpath, + session=session, data=b'') + # First mirror is how we learn the SDK's session id, which is what the + # next turn resumes from. + if not subpath and not session.sdk_session_id: + session.sdk_session_id = sdk_session_id + session.save(update_fields=['sdk_session_id']) + + existing = bytes(transcript.data or b'') + addition = b''.join(json.dumps(e).encode('utf-8') + b'\n' for e in entries) + # Read-modify-write of the whole blob, so bound it: the SDK surfaces the refusal as + # a mirror_error rather than silently losing resume. + if len(existing) + len(addition) > AgentTranscript.MAX_BYTES: + logger.error("agent: transcript %s over %d bytes, refusing the mirror", + sdk_session_id, AgentTranscript.MAX_BYTES) + return HttpResponse('transcript too large', status=413) + transcript.data = existing + addition + transcript.save() + return HttpResponse(status=204) diff --git a/cloudpebble/ide/api/project.py b/cloudpebble/ide/api/project.py index feda84d..1b40d12 100644 --- a/cloudpebble/ide/api/project.py +++ b/cloudpebble/ide/api/project.py @@ -22,6 +22,7 @@ from ide.tasks.git import do_import_github from ide.utils.alloy_templates import list_alloy_templates, build_template_archive from ide.utils.c_templates import list_c_templates, build_c_template_archive +from utils.agent_token import allow_agent_token from utils.td_helper import send_td_event from ide.utils.crypto import encrypt_value, decrypt_value, ENV_VAR_MASK from utils.jsonview import json_view, BadRequest @@ -404,6 +405,7 @@ """ +@allow_agent_token @require_safe @login_required @json_view @@ -465,6 +467,7 @@ def project_info(request, project_id): } +@allow_agent_token @require_POST @login_required @json_view @@ -496,6 +499,7 @@ def _serialize_build(build, project): } +@allow_agent_token @require_safe @login_required @json_view @@ -526,6 +530,7 @@ def build_history(request, project_id): return {"builds": out} +@allow_agent_token @require_safe @login_required @json_view @@ -544,6 +549,7 @@ def build_log(request, project_id, build_id): return {"log": log} +@allow_agent_token @require_safe @login_required @json_view @@ -559,6 +565,7 @@ def build_info(request, project_id, build_id): } +@allow_agent_token @require_safe @login_required def build_download(request, project_id, build_id, filename): diff --git a/cloudpebble/ide/api/resource.py b/cloudpebble/ide/api/resource.py index 729ee9e..a9f7417 100644 --- a/cloudpebble/ide/api/resource.py +++ b/cloudpebble/ide/api/resource.py @@ -9,6 +9,7 @@ from django.views.decorators.http import require_POST, require_safe from ide.models.project import Project from ide.models.files import ResourceFile, ResourceIdentifier, ResourceVariant +from utils.agent_token import allow_agent_token from utils.td_helper import send_td_event from utils.jsonview import json_view, BadRequest import utils.s3 as s3 @@ -36,20 +37,29 @@ def decode_resource_id_options(request): } +@allow_agent_token @require_POST @login_required @json_view def create_resource(request, project_id): project = get_object_or_404(Project, pk=project_id, owner=request.user) - kind = request.POST['kind'] - resource_ids = json.loads(request.POST['resource_ids']) + # get, not []: a caller that omits one of these -- the agent, or anything + # else driving this by API -- deserves "kind is required", not a 500 with a + # MultiValueDictKeyError traceback. + try: + kind = request.POST['kind'] + file_name = request.POST['file_name'] + resource_ids = json.loads(request.POST['resource_ids']) + new_tags = json.loads(request.POST.get('new_tags', '[]')) + except KeyError as e: + raise BadRequest(_("Missing required field: %s") % e.args[0]) + except ValueError: + raise BadRequest(_("resource_ids and new_tags must be valid JSON.")) posted_file = request.FILES.get('file', None) - file_name = request.POST['file_name'] if kind == 'font': ext = os.path.splitext(file_name)[1].lower() if ext not in ('.ttf', '.otf'): raise BadRequest(_("Font resources must have a .ttf or .otf file extension.")) - new_tags = json.loads(request.POST['new_tags']) resources = [] try: with transaction.atomic(): @@ -84,12 +94,15 @@ def create_resource(request, project_id): }} +@allow_agent_token @require_safe @login_required @json_view def resource_info(request, project_id, resource_id): project = get_object_or_404(Project, pk=project_id, owner=request.user) - resource = get_object_or_404(ResourceFile, pk=resource_id) + # Scoped to the project in the URL, not just to a valid pk: an agent token is + # minted for one project, and the id alone would let it read any other. + resource = get_object_or_404(ResourceFile, pk=resource_id, project=project) resources = resource.get_identifiers() send_td_event('cloudpebble_open_file', data={ @@ -112,6 +125,7 @@ def resource_info(request, project_id, resource_id): } +@allow_agent_token @require_POST @login_required @json_view @@ -158,6 +172,7 @@ def delete_variant(request, project_id, resource_id, variant): }} +@allow_agent_token @require_POST @login_required @json_view @@ -230,10 +245,15 @@ def update_resource(request, project_id, resource_id): }} +@allow_agent_token @require_safe @login_required def show_resource(request, project_id, resource_id, variant): - resource = get_object_or_404(ResourceFile, pk=resource_id, project__owner=request.user) + # project=, not project__owner=: the owner check alone let any of the user's + # projects be read through any other's URL, which an agent token scoped to a + # single project must not be able to do. + project = get_object_or_404(Project, pk=project_id, owner=request.user) + resource = get_object_or_404(ResourceFile, pk=resource_id, project=project) if variant == '0': variant = '' diff --git a/cloudpebble/ide/api/source.py b/cloudpebble/ide/api/source.py index 4f5d5f0..50b7964 100644 --- a/cloudpebble/ide/api/source.py +++ b/cloudpebble/ide/api/source.py @@ -9,12 +9,14 @@ from django.utils.translation import gettext as _ from ide.models.project import Project from ide.models.files import SourceFile +from utils.agent_token import allow_agent_token from utils.td_helper import send_td_event from utils.jsonview import json_view, BadRequest __author__ = "katharine" +@allow_agent_token @require_POST @login_required @json_view @@ -63,6 +65,7 @@ def create_source_file(request, project_id): } +@allow_agent_token @require_safe @csrf_protect @login_required @@ -162,6 +165,7 @@ def rename_source_file(request, project_id, file_id): } +@allow_agent_token @require_POST @login_required @json_view @@ -191,6 +195,7 @@ def save_source_file(request, project_id, file_id): return {"modified": time.mktime(source_file.last_modified.utctimetuple())} +@allow_agent_token @require_POST @login_required @json_view @@ -257,6 +262,7 @@ def create_binary_source_file(request, project_id): } +@allow_agent_token @require_safe @csrf_protect @login_required @@ -280,6 +286,7 @@ def download_source_file(request, project_id, file_id): return response +@allow_agent_token @require_POST @login_required @json_view diff --git a/cloudpebble/ide/management/__init__.py b/cloudpebble/ide/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudpebble/ide/management/commands/__init__.py b/cloudpebble/ide/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudpebble/ide/management/commands/release_orphaned_agent_turns.py b/cloudpebble/ide/management/commands/release_orphaned_agent_turns.py new file mode 100644 index 0000000..65f405a --- /dev/null +++ b/cloudpebble/ide/management/commands/release_orphaned_agent_turns.py @@ -0,0 +1,33 @@ +"""Release agent turns whose relay died with the process that owned it. + +A turn is relayed by a thread inside a web worker, so a deploy, a restart or a +crash takes it with no terminal event: the session stays 'running', the composer +stays disabled, every new message is refused with "the agent is already working", +and nothing recovers it until heal_if_stale's timeout -- over half an hour. + +Nothing relayed by *this* process can survive its restart, so at startup every +session still marked running is by definition orphaned. Run from docker_start.sh +before the server binds. +""" +from django.core.management.base import BaseCommand + +from ide.api.agent import _append +from ide.models.agent import AgentSession + + +class Command(BaseCommand): + help = "Mark agent sessions left 'running' by a dead relay as failed." + + def handle(self, *args, **options): + orphans = list(AgentSession.objects.filter(status='running')) + for session in orphans: + session.status = 'error' + session.save(update_fields=['status']) + # A terminal event, so an open browser re-enables its composer rather + # than sitting on a spinner that will never resolve. + _append(session, 'system', 'error', { + 'message': "The agent was interrupted by a server restart. " + "Say continue and it will pick up where it left off.", + 'kind': 'relay', + }) + self.stdout.write("released %d orphaned agent turn(s)" % len(orphans)) diff --git a/cloudpebble/ide/migrations/0014_agent.py b/cloudpebble/ide/migrations/0014_agent.py new file mode 100644 index 0000000..b16a9a8 --- /dev/null +++ b/cloudpebble/ide/migrations/0014_agent.py @@ -0,0 +1,63 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ide', '0013_github_hook_force'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AgentSession', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sdk_session_id', models.CharField(blank=True, max_length=64)), + ('status', models.CharField(choices=[('idle', 'Idle'), ('running', 'Running'), ('error', 'Error'), ('cancelled', 'Cancelled')], default='idle', max_length=16)), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('last_active', models.DateTimeField(auto_now=True)), + ('turn_count', models.IntegerField(default=0)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agent_sessions', to='ide.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agent_sessions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'cloudpebble_agent_sessions', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AgentTranscript', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sdk_session_id', models.CharField(db_index=True, max_length=64)), + ('subpath', models.CharField(blank=True, default='', max_length=128)), + ('data', models.BinaryField(default=b'')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transcripts', to='ide.agentsession')), + ], + options={ + 'db_table': 'cloudpebble_agent_transcripts', + 'abstract': False, + 'unique_together': {('sdk_session_id', 'subpath')}, + }, + ), + migrations.CreateModel( + name='AgentMessage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('seq', models.IntegerField()), + ('role', models.CharField(choices=[('user', 'User'), ('assistant', 'Assistant'), ('tool', 'Tool'), ('system', 'System')], max_length=16)), + ('content', models.JSONField(default=dict)), + ('created', models.DateTimeField(auto_now_add=True)), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ide.agentsession')), + ], + options={ + 'db_table': 'cloudpebble_agent_messages', + 'ordering': ['seq'], + 'abstract': False, + 'unique_together': {('session', 'seq')}, + }, + ), + ] diff --git a/cloudpebble/ide/migrations/0015_agent_credential.py b/cloudpebble/ide/migrations/0015_agent_credential.py new file mode 100644 index 0000000..e05c0f5 --- /dev/null +++ b/cloudpebble/ide/migrations/0015_agent_credential.py @@ -0,0 +1,35 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('ide', '0014_agent'), + ] + + operations = [ + migrations.CreateModel( + name='AgentCredential', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('provider', models.CharField(choices=[('anthropic', 'Anthropic'), + ('openrouter', 'OpenRouter')], + max_length=16)), + ('secret_kind', models.CharField(choices=[('api_key', 'API key'), + ('oauth', 'OAuth token')], + default='api_key', max_length=16)), + ('encrypted_secret', models.TextField()), + ('model', models.CharField(blank=True, max_length=128)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, + related_name='agent_credential', + to=settings.AUTH_USER_MODEL)), + ], + options={'db_table': 'cloudpebble_agent_credentials', 'abstract': False}, + ), + ] diff --git a/cloudpebble/ide/migrations/0016_agent_credential_auth_state.py b/cloudpebble/ide/migrations/0016_agent_credential_auth_state.py new file mode 100644 index 0000000..c0ed2d6 --- /dev/null +++ b/cloudpebble/ide/migrations/0016_agent_credential_auth_state.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [('ide', '0015_agent_credential')] + + operations = [ + migrations.AddField( + model_name='agentcredential', + name='auth_failed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='agentcredential', + name='auth_error', + field=models.TextField(blank=True), + ), + ] diff --git a/cloudpebble/ide/models/__init__.py b/cloudpebble/ide/models/__init__.py index af9e197..b8ddd5e 100644 --- a/cloudpebble/ide/models/__init__.py +++ b/cloudpebble/ide/models/__init__.py @@ -5,3 +5,4 @@ from ide.models.project import * from ide.models.user import * from ide.models.dependency import * +from ide.models.agent import * diff --git a/cloudpebble/ide/models/agent.py b/cloudpebble/ide/models/agent.py new file mode 100644 index 0000000..29f4407 --- /dev/null +++ b/cloudpebble/ide/models/agent.py @@ -0,0 +1,157 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ide.models.meta import IdeModel +from ide.utils.crypto import decrypt_value, encrypt_value +from ide.models.project import Project + +__author__ = 'ericmigi' + + +class AgentSession(IdeModel): + """ One AI agent conversation about one project. """ + + STATUS_IDLE = 'idle' + STATUS_RUNNING = 'running' + STATUS_ERROR = 'error' + STATUS_CANCELLED = 'cancelled' + STATUS_CHOICES = ( + (STATUS_IDLE, _('Idle')), + (STATUS_RUNNING, _('Running')), + (STATUS_ERROR, _('Error')), + (STATUS_CANCELLED, _('Cancelled')), + ) + + project = models.ForeignKey(Project, related_name='agent_sessions', on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='agent_sessions', on_delete=models.CASCADE) + # The Agent SDK's own session id, used for resume. Empty until the first turn completes. + sdk_session_id = models.CharField(max_length=64, blank=True) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_IDLE) + created = models.DateTimeField(auto_now_add=True, db_index=True) + last_active = models.DateTimeField(auto_now=True) + turn_count = models.IntegerField(default=0) + + class Meta(IdeModel.Meta): + db_table = 'cloudpebble_agent_sessions' + + +class AgentMessage(IdeModel): + """ A rendered chat event. This is what the chat panel displays; it is not the + SDK transcript (see AgentTranscript). """ + + ROLE_USER = 'user' + ROLE_ASSISTANT = 'assistant' + ROLE_TOOL = 'tool' + ROLE_SYSTEM = 'system' + ROLE_CHOICES = ( + (ROLE_USER, _('User')), + (ROLE_ASSISTANT, _('Assistant')), + (ROLE_TOOL, _('Tool')), + (ROLE_SYSTEM, _('System')), + ) + + session = models.ForeignKey(AgentSession, related_name='messages', on_delete=models.CASCADE) + seq = models.IntegerField() + role = models.CharField(max_length=16, choices=ROLE_CHOICES) + content = models.JSONField(default=dict) + created = models.DateTimeField(auto_now_add=True) + + class Meta(IdeModel.Meta): + db_table = 'cloudpebble_agent_messages' + # Also the index that serves ?since=. + unique_together = (('session', 'seq'),) + ordering = ['seq'] + + +class AgentTranscript(IdeModel): + """ SessionStore backing for the Agent SDK: append-only JSONL, one entry per line. + + One row per (sdk_session_id, subpath). subpath is empty for the main transcript and + set for subagent transcripts -- see claude_agent_sdk.types.SessionKey. + """ + + # Beyond this the mirror is refused: a 12-turn watchface transcript is orders of + # magnitude smaller, and the row is read-modify-written on every append. + MAX_BYTES = 8 * 1024 * 1024 + + sdk_session_id = models.CharField(max_length=64, db_index=True) + subpath = models.CharField(max_length=128, blank=True, default='') + session = models.ForeignKey(AgentSession, related_name='transcripts', on_delete=models.CASCADE) + data = models.BinaryField(default=b'') + + class Meta(IdeModel.Meta): + db_table = 'cloudpebble_agent_transcripts' + unique_together = (('sdk_session_id', 'subpath'),) + + +class AgentCredential(IdeModel): + """A user's own model provider, so they can spend their own quota. + + Supported, per Anthropic's own guidance that Agent SDK usage in third-party + applications may authenticate with a user's Claude subscription: + https://support.claude.com/en/articles/15036540 + + The secret is Fernet-encrypted with the same helper the project env-var + feature uses, is never returned to the browser, and is decrypted only when a + turn is dispatched. + """ + + PROVIDER_ANTHROPIC = 'anthropic' + PROVIDER_OPENROUTER = 'openrouter' + # No OpenAI entry: OpenAI serves no Anthropic-format /v1/messages endpoint, + # so the Agent SDK cannot drive it. OpenAI models go through OpenRouter. + PROVIDER_CHOICES = ( + (PROVIDER_ANTHROPIC, 'Anthropic'), + (PROVIDER_OPENROUTER, 'OpenRouter'), + ) + + # An Anthropic secret is either an API key or an OAuth token from + # `claude setup-token`; the SDK takes both, on different env vars. + KIND_API_KEY = 'api_key' + KIND_OAUTH = 'oauth' + KIND_CHOICES = ((KIND_API_KEY, 'API key'), (KIND_OAUTH, 'OAuth token')) + + user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='agent_credential', + on_delete=models.CASCADE) + provider = models.CharField(max_length=16, choices=PROVIDER_CHOICES) + secret_kind = models.CharField(max_length=16, choices=KIND_CHOICES, default=KIND_API_KEY) + encrypted_secret = models.TextField() + # Free-form so a user can name any model their provider serves, including + # OpenRouter's "vendor/model" strings, without waiting on us to allowlist it. + model = models.CharField(max_length=128, blank=True) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + # Set when a turn fails authentication, cleared when one succeeds. Tokens + # expire and keys get rotated, so the panel has to be able to say so instead + # of failing the same way forever. + auth_failed_at = models.DateTimeField(null=True, blank=True) + auth_error = models.TextField(blank=True) + + class Meta(IdeModel.Meta): + db_table = 'cloudpebble_agent_credentials' + + def __unicode__(self): + return u"%s/%s" % (self.user_id, self.provider) + + @property + def masked(self): + """Enough to recognise which key is stored, useless if intercepted.""" + try: + secret = decrypt_value(self.encrypted_secret) + except Exception: + return '(unreadable)' + return '%s...%s' % (secret[:7], secret[-4:]) if len(secret) > 15 else '******' + + def secret(self): + return decrypt_value(self.encrypted_secret) + + def set_secret(self, plaintext): + self.encrypted_secret = encrypt_value(plaintext) + # A new secret deserves a clean slate. + self.auth_failed_at = None + self.auth_error = '' + + @property + def needs_reauth(self): + return self.auth_failed_at is not None diff --git a/cloudpebble/ide/static/ide/css/agent.css b/cloudpebble/ide/static/ide/css/agent.css new file mode 100644 index 0000000..637bdea --- /dev/null +++ b/cloudpebble/ide/static/ide/css/agent.css @@ -0,0 +1,392 @@ +/* AI agent chat panel — the third column. Sits left of the sidebar. */ + +#chat-wrapper { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 360px; + background-color: #232323; + border-right: 1px solid #111; + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 2; +} + +/* Shift the existing two columns over to make room. */ +body.with-chat #sidebar, +body.with-chat #emulator-container { left: 360px; } +body.with-chat #pane-parent { left: 645px; } + +body.with-chat.chat-collapsed #sidebar, +body.with-chat.chat-collapsed #emulator-container { left: 28px; } +body.with-chat.chat-collapsed #pane-parent { left: 313px; } +body.with-chat.chat-collapsed #chat-wrapper { width: 28px; } +body.with-chat.chat-collapsed #chat-wrapper > *:not(#chat-rail) { display: none; } + +#chat-rail { + display: none; + flex: 1; + cursor: pointer; + color: #777; + text-align: center; + padding-top: 10px; +} +#chat-rail:hover { color: #fff; } +#chat-rail:after { content: '\25B8'; } +body.with-chat.chat-collapsed #chat-rail { display: block; } + +.chat-header { + flex: 0 0 auto; + padding: 8px 10px; + background-color: #292929; + border-bottom: 1px solid #111; + color: #ccc; + font-family: PFD-Regular, 'Helvetica Neue', Helvetica, Arial, sans-serif; + text-transform: uppercase; + font-size: 13px; + line-height: 20px; +} + +.chat-header .chat-header-buttons { + float: right; +} + +.chat-header a { + color: #999; + text-decoration: none; + margin-left: 8px; +} + +.chat-header a:hover { color: #fff; } + +#chat-log { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + padding: 10px; + color: #ddd; + font-size: 13px; + line-height: 1.45; +} + +.chat-msg { + white-space: pre-wrap; + word-wrap: break-word; + padding: 7px 10px; + border-radius: 6px; + margin-bottom: 8px; +} + +.chat-msg-user { + background-color: #3a4a5a; + color: #fff; + margin-left: 30px; +} + +.chat-msg-assistant { + background-color: #2d2d2d; +} + +.chat-msg-system { + color: #999; + font-style: italic; +} + +/* Cards: tool calls, tool results, file edits. */ + +.chat-card { + background-color: #2a2a2a; + border: 1px solid #1c1c1c; + border-radius: 4px; + margin-bottom: 8px; + font-size: 12px; +} + +.chat-card-header { + padding: 5px 8px; + cursor: pointer; + color: #bbb; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-card-header a { color: #7ab8e8; } + +.chat-caret { + display: inline-block; + width: 10px; + color: #777; +} + +.chat-tool-name { + font-family: Menlo, Monaco, Consolas, monospace; + color: #d0d0d0; +} + +.chat-summary { + color: #8a8a8a; + margin-left: 6px; +} + +.chat-status { margin-right: 6px; } +.chat-ok { color: #6ab04c; } +.chat-fail { color: #d9534f; } + +.chat-card-body { + border-top: 1px solid #1c1c1c; + max-height: 320px; + overflow: auto; +} + +.chat-card-body pre, +.chat-card pre { + margin: 0; + padding: 6px 8px; + background: transparent; + border: none; + border-radius: 0; + color: #c8c8c8; + font-size: 11px; + line-height: 1.35; + white-space: pre; +} + +.chat-diff div { white-space: pre; } +.chat-diff-add { color: #8fce6b; } +.chat-diff-del { color: #e08080; } +.chat-diff-hunk { color: #7a9fc0; } + +.chat-shot { + display: block; + margin: 6px auto; + image-rendering: pixelated; + background-color: #000; + max-width: 100%; +} + +/* Build status chip. */ + +.chat-chip { + display: inline-block; + padding: 3px 9px; + margin-bottom: 8px; + border-radius: 10px; + background-color: #3a3a3a; + font-size: 12px; +} + +.chat-chip a { color: #ddd; text-decoration: none; } +.chat-build-succeeded { background-color: #2f5d2a; } +.chat-build-failed { background-color: #6b2f2c; } +/* The build ran but we could not confirm the outcome — not the same as failed. */ +.chat-build-unknown { background-color: #5a4a22; } + +.chat-error { + background-color: #4a2320; + border: 1px solid #6b2f2c; + color: #f0c8c4; + padding: 7px 10px; + border-radius: 4px; + margin-bottom: 8px; + font-size: 12px; +} + +.chat-error a { color: #ffb3ac; } + +#chat-thinking { + color: #888; + font-size: 12px; + font-style: italic; + margin-bottom: 8px; +} + +/* Composer. */ + +.chat-composer { + flex: 0 0 auto; + padding: 8px; + background-color: #292929; + border-top: 1px solid #111; +} + +#chat-input { + width: 100%; + box-sizing: border-box; + resize: none; + height: 58px; + margin-bottom: 6px; + background-color: #1e1e1e; + border: 1px solid #111; + color: #eee; + font-size: 13px; + padding: 6px; +} + +#chat-input:focus { + border-color: #444; + outline: none; + box-shadow: none; +} + +.chat-composer .btn { float: right; } +.chat-composer:after { content: ''; display: block; clear: both; } + +/* Turn progress: spinner, current activity, elapsed time. Turns run 40s to + several minutes, so a static label reads as a hang. */ +#chat-thinking { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + font-size: 12px; + color: #999; +} +#chat-thinking .chat-spinner { + color: #ff4700; + font-size: 13px; + line-height: 1; +} +#chat-thinking .chat-thinking-what { flex: 1; } +#chat-thinking .chat-thinking-time { + font-variant-numeric: tabular-nums; + color: #777; +} + +/* Model settings: which provider builds your watchface, and on whose quota. */ +#chat-model { + font-size: 11px; + color: #999; + margin-right: 10px; + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: bottom; +} +#chat-model:hover { color: #ff4700; } +#chat-settings { + flex: 1; + overflow-y: auto; + padding: 12px; + font-size: 12px; +} +#chat-settings .chat-settings-intro { color: #999; margin: 0 0 12px; } +#chat-settings label { display: block; color: #bbb; margin-bottom: 4px; } +#chat-settings select, +#chat-settings input[type=text], +#chat-settings input[type=password] { + width: 100%; + box-sizing: border-box; + margin-top: 3px; +} +#chat-settings .chat-settings-row { margin-top: 10px; } +#chat-settings .chat-hint { color: #777; font-size: 11px; margin: 4px 0 0; } +#chat-settings .chat-hint code { font-size: 11px; } +#chat-settings .chat-settings-actions { margin-top: 14px; } +#chat-settings .chat-settings-actions a { margin-left: 10px; color: #999; } +#chat-settings .chat-settings-status { color: #999; margin-top: 10px; min-height: 1em; } +/* A credential that stopped working: the user has to act, so make it visible. */ +#chat-model.chat-model-broken { color: #e74c3c; } +#chat-model.chat-model-broken:before { content: '⚠ '; } + +/* Guided setup: getting a key is the step people get stuck on. */ +#chat-settings .chat-steps { + margin: 12px 0 0 16px; + padding: 0; + color: #bbb; + line-height: 1.5; +} +#chat-settings .chat-steps li { margin-bottom: 7px; } +#chat-settings .chat-steps a { color: #ff8a5c; } +#chat-settings .chat-command { margin-top: 4px; } +#chat-settings .chat-copy { + display: inline-block; + background: #1b1b1b; + border: 1px solid #3a3a3a; + border-radius: 3px; + padding: 3px 7px; + font-size: 11px; + color: #e0e0e0; + cursor: pointer; + user-select: all; +} +#chat-settings .chat-copy:hover { border-color: #ff4700; } +#chat-settings .chat-connect { margin-top: 4px; } + +/* Messages waiting for the current turn to finish. Deliberately quieter than a + sent message: they have not happened yet. */ +#chat-queue { + margin-bottom: 6px; +} + +.chat-queued { + display: flex; + align-items: baseline; + gap: 6px; + padding: 4px 8px; + margin-bottom: 4px; + border-left: 2px solid #4a4a4a; + background-color: #262626; + color: #9a9a9a; + font-size: 12px; +} + +.chat-queued-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-queued-drop { + color: #8a8a8a; + text-decoration: none; + /* A comfortable tap target on a phone without making the row taller. */ + padding: 0 4px; +} + +.chat-queued-drop:hover { color: #d9534f; text-decoration: none; } + +/* One-click recovery for the failures the agent cannot fix itself: the emulator + belongs to this browser tab, not to the agent. */ +.chat-card-action { + padding: 6px 8px; + border-top: 1px solid #1c1c1c; +} + +.chat-emulator-open { + width: 100%; +} + +/* What an empty panel says. Removed as soon as any real message arrives -- it is + guidance, not part of the transcript. */ +.chat-intro { + color: #8a8a8a; + font-size: 12px; + line-height: 1.5; + padding: 4px 2px; +} + +.chat-intro p { margin: 0 0 8px; } + +.chat-intro-label { color: #6f6f6f; } + +a.chat-example { + display: block; + padding: 6px 8px; + margin-bottom: 4px; + border: 1px solid #2e2e2e; + border-radius: 4px; + color: #9fc4e0; + text-decoration: none; +} + +a.chat-example:hover { + border-color: #3d5567; + background-color: #262626; + text-decoration: none; +} diff --git a/cloudpebble/ide/static/ide/js/__tests__/agent-queue.test.js b/cloudpebble/ide/static/ide/js/__tests__/agent-queue.test.js new file mode 100644 index 0000000..d168324 --- /dev/null +++ b/cloudpebble/ide/static/ide/js/__tests__/agent-queue.test.js @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +/** + * The queue and the running/idle state are the two things that decide whether the + * panel is usable mid-turn: get them wrong and the user sees no spinner, no Stop + * button, and "the agent is already working" when they try to say anything. + * + * jQuery and underscore are not vendored in this repo (the IDE loads them from a + * CDN), so this stubs the handful of calls the panel makes rather than pulling in + * two dependencies for one test. + */ +function chainable(store) { + const node = { + _children: [], + _text: '', + _shown: true, + empty() { node._children = []; return node; }, + children(sel) { + if (sel === '.chat-intro') { + return { remove() { node._removedIntro = true; } }; + } + return { length: node._children.length }; + }, + append(child) { node._children.push(child); return node; }, + prepend(child) { node._children.unshift(child); return node; }, + text(t) { if (t === undefined) return node._text; node._text = t; return node; }, + attr() { return node; }, + addClass() { return node; }, + removeClass() { return node; }, + toggleClass() { return node; }, + html() { return node; }, + val(v) { if (v === undefined) return node._val || ''; node._val = v; return node; }, + prop() { return node; }, + focus() { return node; }, + hide() { node._shown = false; return node; }, + show() { node._shown = true; return node; }, + toggle(on) { node._shown = !!on; return node; }, + slideToggle() { return node; }, + find() { return chainable(store); }, + click(fn) { if (fn) node._click = fn; return node; }, + keydown() { return node; }, + change() { return node; }, + on() { return node; }, + length: 1, + }; + return node; +} + +function loadAgent() { + const store = {}; + const registry = {}; + const $ = vi.fn((sel) => { + if (typeof sel === 'function') { store.ready = sel; return chainable(store); } + const key = String(sel); + // '
' builds a new element every time; '#chat-queue' is a lookup + // of the same one. Caching both would make two queue rows share a node, + // and then share a click handler. + if (key.charAt(0) === '<') return chainable(store); + if (!registry[key]) registry[key] = chainable(store); + return registry[key]; + }); + $.trim = (s) => (s || '').trim(); + + global.$ = $; + global.jQuery = $; + global._ = { + each: (list, fn) => (list || []).forEach(fn), + some: (list, fn) => (list || []).some(fn), + isFunction: (f) => typeof f === 'function', + }; + global.gettext = (s) => s; + global.interpolate = (s, args) => s.replace(/%s/g, () => args.shift()); + global.PROJECT_ID = 1; + global.localStorage = { getItem: () => null, setItem: () => {} }; + global.Ajax = { Post: vi.fn(() => Promise.resolve({})), Get: vi.fn(() => Promise.resolve({})) }; + global.EventSource = vi.fn(() => ({ close: vi.fn() })); + global.CloudPebble = { + Sidebar: { Refresh: vi.fn() }, + Compile: { Show: vi.fn() }, + }; + global.SharedPebble = { isVirtual: () => false, getPlatformName: () => 'emery' }; + + const code = readFileSync(resolve(__dirname, '..', 'agent.js'), 'utf8'); + new Function(code)(); + return { agent: global.CloudPebble.Agent, registry, $ }; +} + +describe('agent chat panel', () => { + let agent, registry; + + beforeEach(() => { + vi.clearAllMocks(); + ({ agent, registry } = loadAgent()); + }); + + it('queues a message instead of sending it while a turn is running', async () => { + agent.Render({ type: 'sync', data: { status: 'running' } }); + agent.Send('and then make it blue'); + + expect(global.Ajax.Post).not.toHaveBeenCalledWith( + expect.stringContaining('/agent/message'), expect.anything()); + expect(agent.Queue()).toEqual(['and then make it blue']); + }); + + it('drops a queued message when its remove control is used', () => { + agent.Render({ type: 'sync', data: { status: 'running' } }); + agent.Send('first'); + agent.Send('second'); + expect(agent.Queue()).toEqual(['first', 'second']); + + const rows = registry['#chat-queue']._children; + rows[0]._children[0]._click({ preventDefault() {} }); + expect(agent.Queue()).toEqual(['second']); + }); + + it('sends the next queued message when the turn ends', async () => { + agent.Render({ type: 'sync', data: { status: 'running' } }); + agent.Send('next thing'); + agent.Render({ type: 'done', data: {} }); + + expect(agent.Queue()).toEqual([]); + expect(global.Ajax.Post).toHaveBeenCalledWith( + expect.stringContaining('/agent/start')); + }); + + it('a sync saying running wins over a replayed terminal event', () => { + // History replays old turns, terminal events and all. Only the sync that + // follows the replay knows whether anything is running NOW. + agent.Render({ seq: 1, type: 'error', data: { kind: 'auth', message: 'expired' } }); + agent.Render({ seq: 2, type: 'sync', data: { status: 'running' } }); + expect(agent.Running()).toBe(true); + }); + + it('a stopped turn does not release the queue', () => { + // Stop means stop. Draining on any transition to idle made Stop launch + // the next queued message, and an expired key burn the whole queue. + agent.Render({ type: 'sync', data: { status: 'running' } }); + agent.Send('one'); + agent.Send('two'); + agent.Render({ type: 'error', data: { kind: 'cancelled', message: 'Stopped.' } }); + + expect(agent.Running()).toBe(false); + expect(agent.Queue()).toEqual(['one', 'two']); + expect(global.Ajax.Post).not.toHaveBeenCalledWith( + expect.stringContaining('/agent/message'), expect.anything()); + }); + + it('an auth failure does not throw the queue at the same wall', () => { + agent.Render({ type: 'sync', data: { status: 'running' } }); + agent.Send('one'); + agent.Render({ type: 'error', data: { kind: 'auth', message: 'expired' } }); + expect(agent.Queue()).toEqual(['one']); + }); + + it('pulls the IDE back in step when the agent changes the project', async () => { + // Otherwise the editor shows stale text and the user's next save is + // rejected as "modified since you last saved it". + vi.useFakeTimers(); + agent.Render({ type: 'file_edit', data: { path: 'src/c/main.c', file_id: 3, diff: '' } }); + agent.Render({ type: 'project_changed', data: { what: 'resources' } }); + expect(global.CloudPebble.Sidebar.Refresh).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + // Debounced: a turn writes several files in a row, and each refresh is a + // whole project/info fetch plus a sidebar rebuild. + expect(global.CloudPebble.Sidebar.Refresh).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it('offers examples in an empty panel, and drops them once anything arrives', () => { + // The panel binds Init on document ready, which the stub never fires. + agent.Init(); + const log = registry['#chat-log']; + expect(log._children.length).toBeGreaterThan(0); + + agent.Render({ type: 'text', role: 'user', data: { text: 'make me a watchface' } }); + // The intro is guidance, not transcript: it goes the moment a real + // message lands, and never comes back on top of history. + expect(log._removedIntro).toBe(true); + }); + + it('starts the best screen the project targets, not the first button', () => { + // The buttons are in DOM order, which begins at Aplite: 144x168 and black + // and white. A colour watchface got designed and verified on it. + const clicked = []; + const original = global.$; + global.$ = Object.assign(function(sel) { + const key = String(sel); + const node = original(sel); + if (key.indexOf('install-in-qemu-') !== -1) { + // Only emery and aplite exist for this project. + const has = key.indexOf('emery') !== -1 || key.indexOf('aplite') !== -1; + return Object.assign(Object.create(node), { + length: has ? 1 : 0, + click() { clicked.push(key); return this; }, + }); + } + return node; + }, original); + + agent.Render({ type: 'sync', data: { status: 'running' } }); + agent.Render({ type: 'tool_result', data: { + tool: 'install', ok: false, summary: 'no emulator is running' } }); + + global.$ = original; + expect(clicked.length).toBe(1); + expect(clicked[0]).toContain('emery'); + }); + + it('starts the emulator itself when a tool asks for one', () => { + // A new user does not know "no emulator is running" means Build & Run -> + // Emery, and should not have to: the panel owns the emulator, so it opens + // it and tells the agent to carry on. + agent.Render({ type: 'sync', data: { status: 'running' } }); + agent.Render({ type: 'tool_result', data: { + tool: 'install', ok: false, + summary: 'no emulator is running -- open the emulator in CloudPebble first' } }); + + expect(global.CloudPebble.Compile.Show).toHaveBeenCalled(); + }); + + it('does not start an emulator while replaying history', () => { + // A reload replays every old failure; none of them should launch a VM. + agent.Render({ type: 'tool_result', data: { + tool: 'install', ok: false, summary: 'no emulator is running' } }); + expect(global.CloudPebble.Compile.Show).not.toHaveBeenCalled(); + }); + + it('shows Stop while running, and Send becomes Queue rather than vanishing', () => { + // Hiding Send left Enter as the only route to the queue, which nothing + // advertises -- clicking where Send had been did nothing at all. + agent.Render({ type: 'sync', data: { status: 'running' } }); + expect(registry['#chat-stop']._shown).toBe(true); + expect(registry['#chat-send']._shown).toBe(true); + expect(registry['#chat-send']._text).toBe('Queue'); + + agent.Render({ type: 'done', data: {} }); + expect(registry['#chat-stop']._shown).toBe(false); + expect(registry['#chat-send']._text).toBe('Send'); + }); +}); diff --git a/cloudpebble/ide/static/ide/js/agent.js b/cloudpebble/ide/static/ide/js/agent.js new file mode 100644 index 0000000..016b84b --- /dev/null +++ b/cloudpebble/ide/static/ide/js/agent.js @@ -0,0 +1,998 @@ +/** + * CloudPebble.Agent — the AI chat panel (third column). + * + * Lives outside the #main-pane system on purpose: it must stay visible while + * the user switches between the editor, Build & Run and the emulator. + */ +CloudPebble.Agent = (function() { + var mSessionID = null; + var mSource = null; + var mLastSeq = 0; + var mRunning = false; + var mLastMessage = null; + var mReconnectTimer = null; + var RECONNECT_DELAY = 3000; + var MAX_RECONNECT_DELAY = 60000; + var mReconnectDelay = RECONNECT_DELAY; + // Which watch the agent has been laying out for, so "Open emulator" starts + // that one rather than guessing. + var mEmulatorPlatform = null; + // Messages typed while a turn is running, sent one at a time as it frees up. + // Client-side and per session: a queued thought is worth less than the round + // trip to store it, and losing it on a reload is what the user expects. + var mQueue = []; + // Kept in step with FATAL_ERROR_KINDS in ide/api/agent.py. + var FATAL_ERROR_KINDS = ['relay', 'timeout', 'cancelled', 'usage_limit', 'auth']; + + /* ---------------------------------------------------------------- log */ + + function scroll_down() { + var log = $('#chat-log')[0]; + if (log) log.scrollTop = log.scrollHeight; + } + + /** Whether the log is close enough to the bottom to be "following". */ + function pinned_to_bottom() { + var log = $('#chat-log')[0]; + if (!log) return true; + return log.scrollHeight - log.scrollTop - log.clientHeight < 40; + } + + function append(el) { + $('#chat-log').children('.chat-intro').remove(); + // Read the position BEFORE inserting: a turn runs for minutes, and + // yanking the view back every time an event lands makes it impossible to + // read a diff or a build log while the agent works. + var follow = pinned_to_bottom(); + $('#chat-log').append(el); + if (follow) scroll_down(); + return el; + } + + function bubble(role, text) { + return $('
').addClass('chat-msg-' + role).text(text || ''); + } + + /** A card with a clickable header that toggles its body. */ + function card(kind, header_content, body) { + var header = $('
').append(header_content); + var wrapper = $('
').addClass('chat-card-' + kind).append(header); + if (body) { + body.addClass('chat-card-body').hide(); + wrapper.append(body); + header.prepend($('').html('▸')); + header.click(function(e) { + if ($(e.target).is('a')) return; + body.slideToggle(120); + var caret = header.find('.chat-caret'); + caret.html(caret.html() === '▸' ? '▾' : '▸'); + }); + } + return wrapper; + } + + function tool_use_card(d) { + var body = $('
').text(JSON.stringify(d.args || {}, null, 2));
+        return card('tool', $('').text(d.tool || gettext("tool")), body);
+    }
+
+    /**
+     * The agent cannot open or reboot the emulator -- it belongs to this browser
+     * tab -- so the panel does it, automatically, the moment a tool asks for one.
+     *
+     * A new user does not know that "no emulator is running" means Build & Run ->
+     * Emery, and should not have to: both projects in the first end-to-end run
+     * built on the first turn and then dead-ended there. So this starts the
+     * emulator and tells the agent to carry on, without being asked.
+     */
+    var EMULATOR_HINTS = ['no emulator is running', 'emulator connection lost',
+                          'emulator never came up', 'rejected the install'];
+
+    function needs_emulator(summary) {
+        var text = (summary || '').toLowerCase();
+        return _.some(EMULATOR_HINTS, function(hint) { return text.indexOf(hint) !== -1; });
+    }
+
+    function wants_restart(summary) {
+        var text = (summary || '').toLowerCase();
+        return text.indexOf('rejected the install') !== -1
+            || text.indexOf('connection lost') !== -1
+            || text.indexOf('never came up') !== -1;
+    }
+
+    function emulator_button(summary) {
+        var restart = wants_restart(summary);
+        var label = restart ? gettext("Restart emulator") : gettext("Open emulator");
+        return $('
+                    {% trans 'Cancel' %}
+                
+

+
+ +
+
+ + + + + +
+
+ {% endif %}