Compare commits

...
44 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 2e2f6c846c docs(uno-q): shaker-campaign findings — position-target DVF, safe settings, sensor placement
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-27 19:53:49 +02:00
Omar SobhandClaude Opus 4.8 0a50476bb5 docs(uno-q): oscillator + DVF commands, gain findings, demo script
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-27 18:28:06 +02:00
Omar SobhandClaude Opus 4.8 139f2b25e5 docs(uno-q): actuator run-book (bring-up, calibration, restart rule)
ACTUATOR.md: hardware (TB6600 PUL=D4/DIR=D5), the stepper/stepper_status agent
tools + relay commands, the calibrated 0..9635 envelope, the safe daily bring-up,
the operating rules (park+zero before restart — a restart resets the MCU zero
while the envelope survives), mismatch recovery, recalibration, and the zigbuild
rebuild recipe. Linked from ONBOARDING.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-27 17:07:47 +02:00
Omar SobhandClaude Opus 4.8 b0f47ec271 feat(uno-q): allow the stepper + stepper_status agent tools in the onboard config
Adds 'stepper' and 'stepper_status' to the risk profile allowed_tools +
auto_approve so the cloud agent can drive the linear actuator within its
calibrated soft limits.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-27 17:04:43 +02:00
Omar Sobh 6642691436 Merge feat/workshop-agent-makeup-flow: workshop redesign + Windows board bring-up 2026-07-27 14:58:26 +02:00
Omar SobhandClaude Opus 4.8 77373c6fc7 feat(deploy): Windows board-attach scripts + WiFi provisioning + Win11 docs
- connect-board.ps1 / .bat: PowerShell twin of connect-board.sh so teams on
  Windows 11 attach the USB Uno Q to the local self-host stack (adb forward +
  self-register; -Watch for auto-reattach; .bat bypasses execution policy)
- provision-wifi.sh: idempotent nmcli join over adb, venue WiFi baked as default
- provision-fleet.sh: run the WiFi step per board during fleet bring-up
- REQUIREMENTS.md: Windows 11 section (Docker+WSL2, native adb, USB driver),
  'does adb see your board?' check, and correct the WiFi reality (board needs
  venue WiFi for the cloud brain; we pre-join it)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-27 14:58:16 +02:00
Omar SobhandClaude Opus 4.8 05207ba986 feat(workshop): reworked phase flow — agent makeup, sidebar advance, responsive
Rebuild the participant flow around the agent itself:
- Phases: Team registration (+ agent name) -> Meet your agent -> Skills &
  Policies (edit the agent's makeup) -> UnoQ Dashboard (React Flow
  configurator) -> Submit. Swapped/renamed the two module pages
  (Module1/2 -> ModuleDashboard/ModuleMakeup).
- Agent name captured at registration, shown on the dashboard and used in
  the chat greeting (live, not hard-coded), pushed to the board.
- One shared conversation across pages (AgentChatContext); Clawd the crab
  animates the LED matrix and reacts to chat status.
- MAKEUP slide-outs: view, edit (wrench), or AI-refine (wand) the agent's
  personality files and save them back to the board.
- Advance button moved into the sidebar for every phase (ProceedContext).
- Dropped the ADD-doc build + submission gate; Submit just records the entry.
- Responsive pass: sidebar auto-collapses below lg, fluid padding, React
  Flow pan/zoom/fit + Controls, architecture panel becomes a mobile overlay.
- Add @xyflow/react for the configurator.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-27 10:55:34 +02:00
Omar SobhandClaude Opus 4.8 0797923933 feat(api): agent identity + personality (makeup) file endpoints
- POST /nodes/:teamId/identity — set the on-board agent's name
  (agents.default.identity.name + reload) so it adopts the team's choice.
- GET/PUT /nodes/:teamId/personality/:file — proxy the node's
  /api/personality allowlist API (SOUL/IDENTITY/USER/AGENTS/TOOLS/
  HEARTBEAT/MEMORY), so the MAKEUP cards can read + overwrite the agent's
  makeup files and restart it. Both proxy the board gateway with the
  node's bearer token.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-27 10:55:34 +02:00
Omar SobhandClaude Opus 4.8 0156f97b36 feat(uno-q): expose participant files to the agent + document mux scan
- Mount each student's own App Lab files into the app container so the
  agent can read/fix them: mount-user-workspace.sh binds ~/sketches (rw),
  ~/ArduinoApps/* (rw), ~/Arduino/libraries (ro) under /app/workspace via
  a root oneshot ordered before arduino-app-cli.service (the /app bind is
  rprivate, so binds must precede container start; App Lab has no app.yaml
  volumes field). Wired into provision-node-app, provision-fleet (systemd,
  best-effort sudo), and package-onboard-app (bundled under host-setup/).
- uno-q-hardware skill: document the mux-aware i2c_scan output format
  (0x70:mux, 0x70.2=0x1d) and tell the agent its student's files live at
  /app/workspace. See USER-WORKSPACE.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 16:35:17 -07:00
Omar SobhandClaude Opus 4.8 a9a5176f7c feat(cockpit): Clawd mascot + live agent chat on Meet your agent
Rework the Meet-your-agent rail into a self-contained agent surface:

- Clawd, the Claude Code pixel crab, animates the LED matrix on a 26x16
  grid, coupled to the chat lifecycle (idle stare / claws pump while
  working / green flash on reply). Static antennas, permanent black eyes.
- A full free-form chat to the default agent (useAgentChat): free text
  goes to the blocking /webhook path so conversational replies actually
  render; the 3 canned prompts stay fire-and-forget + SSE (tool turns).
- Chat and the working log are separate panes; the 3 prompts became
  starter buttons; the ZeroClaw runtime + channels moved into an
  Advanced drawer. The whole agent rail is now theme-aware (light/dark).
- Strip the phase eyebrows from the sidebar + page headers; reduce the
  EnvSetup left column to editorial copy.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 16:35:17 -07:00
Omar SobhandClaude Opus 4.8 5975287d56 feat(web): 3D tower scene + collapsible sidebar layout
Recast the cockpit shell: the phase stepper becomes a collapsible LEFT sidebar
(brand + vertical stepper + theme), the center is the editorial content, and the
right pane on Team Registration is a native three.js scene — the Meridian Tower
in a procedural Turin/Po-valley landscape — that slides from night to day the
moment the board connects.

- TowerScene.tsx: the .dc.html scene ported to a native React component (no
  iframe), [email protected] via npm, day/night driven by a `night` prop (lerped),
  StrictMode-safe WebGL teardown. Code-split so three.js loads only when the
  scene renders. Sky textures in public/skyscraper/.
- CockpitLayout: left sidebar + content + tower/rail right pane; tower stretches
  to the content height. Stepper is now a vertical, collapsible list.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 12:52:50 -07:00
Omar SobhandClaude Opus 4.8 4dd3681eef refactor(web): reorganize phases + fix Open-your-node URL
Move sections to where they read better:
- Phase 1 (Team registration): drop the "Your Agent" block.
- Phase 2 (Meet your agent): host the "Your Agent" section (say-hi / Telegram /
  voice) AND the agent chat under it; remove "Pick your domain".
- Module 1 (Domain & events): "Pick your domain" moves here (no more read-only
  carry) alongside the ADD Layer 1 card.
- Module 2 (Skills & policies): the chat is gone; the L2/L3 ADD cards show
  directly (still auto-prefilled if the three prompts ran in Meet your agent).

Also: OpenYourNode opens the browser-reachable board URL. In self-host/USB mode
the stored url is container-facing (host.docker.internal) which the browser
can't resolve — derive the current host on :8080 instead. Tests updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 12:52:50 -07:00
Omar SobhandClaude Opus 4.8 48b88b0f0d docs(uno-q): REQUIREMENTS.md for the self-host USB workshop
Student pre-workshop checklist for the confirmed model: each team runs the Docker
stack locally + board over USB, everything localhost, board auto-connects (no
code, no accounts). Lists the board-laptop prereqs (Docker, adb, git, disk,
pre-fetched bundle), the on-the-day flow, a pre-flight self-check, and an
instructor checklist for the remaining decisions.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 10:46:28 -07:00
Omar SobhandClaude Opus 4.8 d91b9b46f0 feat: self-host USB auto-connect (LOCAL_MODE) — no claim code
In the self-host model each team runs the stack locally with one board 1:1 over
USB, so the claim code (which only disambiguated boards in a shared pool) is
unnecessary. Add a LOCAL_MODE that auto-registers + auto-binds the board:

- API: `LOCAL_MODE` env + `GET /mode`; `POST /claim {local:true}` codelessly
  binds the single board (`claimLocal`, rebinding a stale claim on a fresh
  single-team stack); `POST /nodes/:team/disconnect` for the participant's own
  release. Shared/LAN code path is unchanged.
- Frontend: `useLocalMode()` + `LocalBoardConnect` — detect → auto-bind →
  "connected"; USB drop keeps the binding and auto-reconnects; explicit
  Disconnect releases and waits for Reconnect. TeamRegistration swaps the code
  card for it only when the API reports localMode.
- deploy/lan: compose sets `LOCAL_MODE=true` + `host.docker.internal:host-gateway`
  (so the containerized API reaches the adb-forwarded board); `connect-board.sh`
  forwards the tunnels and registers the board with the local stack (`--watch`
  re-attaches on every reconnect).

Validated end-to-end through the Docker stack: mode → auto-bind → live status +
matrix mirror → disconnect → reconnect. 222 tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 10:46:28 -07:00
Omar SobhandClaude Opus 4.8 c761c510a6 chore(uno-q): enforce a single node app in recover.sh
Add a single-app invariant step: set the boot default to the canonical app, stop
any stray *-main-1 container that would collide on :8080/:9999, and ensure the
canonical app is running. Prevents the duplicate-"ZeroClaw Node" collision from
recurring after a reboot/re-plug.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 09:26:11 -07:00
Omar SobhandClaude Opus 4.8 feeff6ac9b fix(web): rail polish — waveform fits, mirror is smooth
- WaveformCanvas: a ResizeObserver keeps the drawing buffer matched to the
  element size (was measured once at mount), so the trace is centered and peaks
  never clip; taller 100px window; clamp to ±45% height.
- useTelemetry: signed oscilloscope signal (idle wave at rest, decaying ring on
  impact, bounded ±0.95) instead of a one-sided magnitude that overshot the box.
- useMatrixMirror: poll 8 → 15 fps. The board renders at ~12 fps and a matrix_get
  round-trip is only ~15 ms, so 8 fps under-sampled (stutter); 15 fps oversamples
  cleanly, well under the ~68 fps ceiling.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 09:26:11 -07:00
Omar SobhandClaude Opus 4.8 e76e27f06d chore(uno-q): auto-recover the board on USB re-plug
Replace the obsolete host-daemon recover.sh with the current App-Lab-node flow:
re-forward the adb tunnels (:8080 gateway, :9999 matrix relay), wait for the
daemon (start the app if needed), re-register the node with the local API, and
sync a fixed claim code onto the matrix so the board + API agree. Adds a
`--watch` mode and a launchd agent (com.redclaw.apess-board-recover.plist) that
runs it on every reconnect — so a re-plug heals itself, no manual step.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 07:38:21 -07:00
Omar SobhandClaude Opus 4.8 1a39457940 feat(web): two-pane "cockpit" redesign with a live board rail
Restructure the workshop flow into the designer's cockpit: a persistent shell
(header + 5-step stepper + sticky instrument rail) wrapping the phase routes via
a React-Router layout route, so the rail stays mounted across navigation.

- Design system: IBM Plex Mono + Newsreader; the full cockpit token set (light
  + dark) in index.css; a working light/dark theme toggle (store `theme` +
  useApplyTheme); a `switch` ui primitive.
- Shell: CockpitLayout, Stepper (forward-gated), PanelChrome helpers. Every
  phase page restyled to the editorial panels + the WORKSHOP-FLOW fixes
  (channels-after-bind, domain framing + L1 prefill, L2/L3 prefill at 3/3,
  in-place submission finale). Store gains `tried` + `channels.saidHi` (v4).
- Live rail (CockpitRail): real node heartbeat + agent activity log + ADD
  progress; sim telemetry (useTelemetry) for the waveform/accel/I2C behind a
  seam, marked SIM.
- LED-matrix PIXEL MIRROR (real): the rail shows exactly what the physical
  matrix displays — API GET /nodes/:team/matrix reads the board's framebuffer
  off the :9999 relay (readMatrixFrame + the `matrixget` relay command);
  useMatrixMirror polls it and unpacks the 104 bits.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 07:38:14 -07:00
Omar SobhandClaude Opus 4.8 6c82b79b01 docs(uno-q): add WORKSHOP-FLOW.md; refresh ONBOARDING to current state
WORKSHOP-FLOW.md is the participant-journey / design-review doc: local-stack
launch, the module→ADD-layer map, each screen's job (does/sees/system), and the
open design questions. Mirrors the shared flow artifact.

ONBOARDING refresh: matrix tools now include matrix_count; Telegram ships
disabled (no more tokenless probe spam); drop the removed "Refine" step; cross-
link the two docs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 20:05:24 -07:00
Omar SobhandClaude Opus 4.8 909b9a7671 feat(uno-q): wire the native matrix_count path (relay + allowlist + skill)
Adds the `count N` command to the host matrix-relay, allowlists + auto-approves
matrix_count for the default agent, and steers the led-matrix skill to use
matrix_count for "count to N once a second" instead of flashing a sketch (which
fails in the App Lab container). Pairs with the zeroclaw sketch/tool changes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 19:04:34 -07:00
Omar SobhandClaude Opus 4.8 8ea9c256b4 fix(nodes): surface the tool result as the agent's reply, not just "finished"
The Module 2 chat showed lifecycle labels ("Agent started / Running i2c_scan /
Agent finished") but never the actual answer. mapNodeEvent dropped the
`tool_call_result` log line, and the observability `agent_end` event is slow and
content-free (the native tool path leaves the final text empty). The real reply
lives in tool_call_result.attributes.output — e.g. i2c_scan →
"No I2C devices responded on the bus." Map it to a `response` activity (or
`error` on a failed tool), so the chat shows the outcome. Falls back to a
"<tool> ✓" marker when a tool returns no output (e.g. a matrix write).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 18:37:17 -07:00
Omar SobhandClaude Opus 4.8 107e5b0955 fix(onboard): ship telegram disabled so a tokenless channel can't spam probes
A tokenless [channels.telegram.default] with enabled=true starts the Telegram
channel anyway; its getUpdates startup probe fails every 5s ("Startup probe:
API error : ; retrying in 5s") and that WARN leaks into the Module 2 chat via
the node activity feed. Ship enabled=false; the setup wizard flips it on with a
real bot_token when a team opts in (api/src/nodes.ts configureTelegram).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 18:25:23 -07:00
Omar SobhandClaude Opus 4.8 4622f8c409 chore: gitignore the local dev copy of the onboarding zip
public/download/ holds a dev-server copy of apess-onboard.zip (baked cloud
token) so the "Download the board app" button works against the Vite dev
server. Never commit it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 18:17:33 -07:00
Omar SobhandClaude Opus 4.8 d3011130a3 fix(onboard): bake the ZeroClaw dashboard into the app (correct web/dist path)
Phase 2's "Open your agent" opens the node's :8080/ dashboard, but the
packager looked for web-dist (wrong) so it was skipped — the gateway then
served "Web dashboard not available". The built dashboard is at web/dist
(cargo xtask web build); carry that into the app's web-dist so :8080/ serves
it. Verified: dashboard + assets load from the board; the rebuilt import zip
includes it (~3.2M).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 18:15:17 -07:00
Omar SobhandClaude Opus 4.8 b335875509 feat(phase1): "Download the board app" button + App Lab import steps
Replace BoardClaim's old `curl | bash` setup command with a download
button (→ /download/apess-onboard.zip, served same-origin by the web
container) and rewrite the steps to the App Lab flow: download → Import an
app → Run → type the code the matrix scrolls. Works on prod and the LAN box
(relative /download URL). Added a test asserting the link + download attr.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 14:23:22 -07:00
Omar SobhandClaude Opus 4.8 c02898a02d feat(deploy): serve the App Lab onboarding zip at /download/ (prod + LAN)
Turnkey hosting for the distributable app: both web containers now mount
deploy/download/ read-only and nginx serves it at /download/<file>
(autoindex off, no-store). Drop dist/apess-onboard.zip into deploy/download/
and students fetch https://apess.redclaw.dev/download/apess-onboard.zip →
App Lab "Import an app" → Run. No image rebuild to update the artifact.

The zip carries a baked cloud token, so deploy/download/*.zip is gitignored
and the URL should be treated as a secret (workshop network / access-gated,
not a public link).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 14:15:15 -07:00
Omar SobhandClaude Opus 4.8 d59bbc0ef3 feat(onboard): emit an App Lab import zip + host-for-download flow
App Lab's own export/import mechanism is a zip whose top dir is the app
name. package-onboard-app.sh now emits dist/apess-onboard.zip alongside the
bundle — verified to round-trip through `arduino-app-cli app import` (a
mac-built zip imports cleanly; no board needed to package). Distribution is
now: build → host the zip on apess.redclaw.dev/download → student downloads
→ App Lab "Import an app" → Run. Replaces the unverified QR path. ONBOARDING
updated with the download/import steps + first-Run auto-install note.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 13:55:44 -07:00
Omar SobhandClaude Opus 4.8 838a0ba334 feat(onboard): distributable "APESS Onboard" App Lab app + packaging
Close the last onboarding gap: one shareable App Lab app a student imports
and Runs, no adb / no host install.

- package-onboard-app.sh: assemble a self-contained bundle — ZeroClaw binary
  (matrix_text + i2c_scan), single-agent config, skills, responder sketch, and
  the BAKED cloud token. Ships without .secret_key (each board mints its own)
  or a team Telegram token; dist/ is gitignored.
- onboard-app/config.toml: the canonical packaged config (proven anthropic.max
  single 'default' agent, matrix + i2c_scan allowlisted, Telegram-ready,
  secrets stripped).
- ONBOARDING.md: the full flow — instructor packages once, student imports +
  Runs, then the wizard. Notes APESS_URL (mDNS/per-team) + LAN reachability.

Validated on-hardware: a freshly-imported bundle mints its key, boots the
cloud agent, and runs the matrix + i2c_scan prompts in-container.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 13:23:12 -07:00
Omar SobhandClaude Opus 4.8 8ce8474d33 feat(uno-q): wire i2c_scan into the relay, allowlist, and modulino skill
- matrix-relay/relay.py: handle the `i2c` command → Bridge.call("i2c_scan").
- config.template: allow + auto-approve i2c_scan on the default risk profile.
- modulino skill: point "confirm a module is present" at i2c_scan (MCU Qwiic
  bus) instead of the Linux i2cdetect.

Completes the container-native path: the three canned prompts (list I2C /
matrix pattern / scroll text) all run through the RouterBridge responder —
no /dev/i2c, no flash — so they work in the App Lab container node.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 12:59:18 -07:00
Omar SobhandClaude Opus 4.8 c12f40837b fix(state): persist to localStorage + explicit Disconnect (survive reload)
The Zustand store persisted to sessionStorage, which is tab-volatile — a
hard reload / new tab / closing the tab dropped the whole session,
including the board binding (device.connected, nodeUrl, teamId). The
workshop runs on the team's own laptop, so switch the store (and the
offline outbox) to localStorage: the binding + progress now survive a
refresh, hard reload, navigation, and tab close.

Clearing is now explicit only: a new `disconnect()` action drops the board
binding (keeping team/domain/phases/ADD so they can re-bind and resume),
surfaced as a "Disconnect" button on the claimed-board card. `reset()`
still wipes everything.

Tests: add an in-memory localStorage to setupTests (Node 22's disabled
experimental localStorage shadows jsdom's) + clear it per test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 10:34:55 -07:00
Omar SobhandClaude Opus 4.8 6a9fad9f10 config(uno-q): one enabled agent — disable cloud/local/chaos demo variants
Make `default` the sole enabled agent in the template so a freshly
provisioned board matches the runtime: web, chat, Refine, and Telegram all
land on the same fully-loaded agent. cloud/local/chaos stay defined (for
opt-in demos of cloud-only / offline / outage) but ship disabled.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 10:12:34 -07:00
Omar SobhandClaude Opus 4.8 5a140ccb17 refactor(agent): route every interaction through one agent constant (AGENT)
Say-hi, the module chat, and Refine each hard-coded the 'cloud' agent
while Telegram ran on 'demo' — different agents for web vs Telegram, easy
to get wrong. Introduce a single `AGENT = 'default'` in api.ts and use it
everywhere. 'default' is the node's fallback agent (the one used when no
alias is given), fully loaded with the cloud model + all skills + all
tools — so no call site can pick a different or missing agent.

Board side (config): 'default' is now the sole enabled agent and owns the
telegram.default channel; 'cloud' and 'demo' are disabled and any stray
request for them falls back to 'default'.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 10:11:40 -07:00
Omar SobhandClaude Opus 4.8 06f2342892 feat(uno-q): resident-responder fast-path for the LED matrix (instant, no flash)
The agent now drives the matrix in <1s via the resident responder instead of
compiling+flashing a sketch (~95s). Pieces:

- matrix-relay/: a tiny container (python-apps-base) running relay.py — a
  TCP:9999 → RouterBridge relay. The ZeroClaw daemon runs on the host for
  hardware access, but the RouterBridge python binding (arduino.app_utils)
  only ships in the App Lab image, so JUST the relay runs containerized,
  mounting the router socket + publishing :9999 to loopback.
- config.template.toml: add matrix_pattern + matrix_text to the default risk
  profile's allowed_tools/auto_approve (the capability filter hides tools not
  listed — this was why matrix_text wasn't exposed to the agent).
- skills/led-matrix: lead with "use matrix_text / matrix_pattern first"; only
  flash for custom frames (flashing overwrites the responder). NOTE skills load
  from each agent's workspace copy, not shared/skills — push to all workspaces.
- provision-host-daemon.sh: flash the responder once + start the relay.

Proven on-hardware: "show a heart" and "scroll GO CLAWS" both instant via the
agent, no flash.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 09:10:34 -07:00
Omar SobhandClaude Opus 4.8 1eb28c6e5e feat(uno-q): host-daemon provisioning (full hardware access) replaces App Lab
The App Lab container can't drive the board — proven on-hardware: I2C/SPI
blocked by the device cgroup + missing i2cdetect, no arduino-cli/Zephyr
toolchain for flashing, and /admin/reload refused (container NAT hides
loopback). Running the ZeroClaw daemon on the host fixes all of it: native
/dev, arduino-cli + toolchain, loopback reload.

- provision-host-daemon.sh: deploy the binary, write the cloud-token
  env-file (raw token + the ZEROCLAW_…api_key config override), install
  Arduino_RouterBridge (Zephyr core prereq), a udev rule for I2C/SPI perms,
  and install+enable zeroclaw-daemon.service. Also tears down the App Lab
  container + clears its default app so it won't grab :8080 on boot.
- zeroclaw-daemon.service: EnvironmentFile for the token (kept off the repo,
  non-fatal if absent) + WorkingDirectory.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 08:39:26 -07:00
Omar SobhandClaude Opus 4.8 7780903278 feat(module2): chat with your agent — three canned prompts, then Layers 2/3
Module 2 is now a chat straight to the agent instead of Build & flash.
Removed the "Open your node" links and the Live board feed card.

New AgentChat component: three imperative canned prompts —
- List the I2C devices on the bus
- Count to 100 and print the value once a second in the LED matrix
- Scroll GO CLAWS on the LED matrix
Each sends to the agent (fire-and-forget, cloud) and streams its
activity (tools/flash/reply) back into the transcript. A prompt is marked
done on its first terminal step (flash/response); an error resets it to
retry. Prompts run one at a time.

Once all three have run successfully, Module 2 reveals "What's next" —
the ADD Layer 2 (Skills) + Layer 3 (Policies & failure) capture — and
Proceed gates on all-three-tried AND L2 + L3 filled.

Note: BuildFlash / LiveBoardFeed / ActorMap are now orphaned (kept for
possible reuse).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 01:39:25 -07:00
Omar SobhandClaude Opus 4.8 c2ef338888 refactor(module1): drop live board feed + actor map; focus on Layer 1
Module 1 is now: domain (carried, read-only) → ADD Layer 1 (domain &
events) → proceed. Removed the Live board feed and Actor map cards. The
node feed hook stays (its online signal still gates Proceed), just no
longer rendered.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 01:20:08 -07:00
Omar SobhandClaude Opus 4.8 f6979a685a refactor(workshop): domain is name-only; Module 1 carries it read-only
Phase 2 "Pick your domain": drop the Refine button + the four-dimension
generation machinery — just capture the domain name and save it for the
modules ahead (DomainPicker is now a single input).

Module 1: show the domain the team named on the previous screen as
read-only ("Your domain" carried over), and remove the "Open your node"
inline link + the editable domain field.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 01:17:17 -07:00
Omar SobhandClaude Opus 4.8 e0bcf51b08 refactor(workshop): "meet your agent" framing for Phase 2
- Phase 1 proceed button → "Meet your agent".
- Phase strip label + Phase 2 heading: "Meet your node" → "Meet your agent".
- Phase 2 intro now describes the APESS agent (Claude on the edge,
  drives the board's devices, on-board fallback).
- "Open your node" card → "Open your agent to explore" (same local
  ZeroClaw dashboard link); Pick-your-domain card kept below it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 01:01:34 -07:00
Omar SobhandClaude Opus 4.8 66723ebd35 feat(telegram): enable the channel on apply + guarantee Telegram-ready boards
The config template seeds channels.telegram.default disabled with an
empty token, so setting only the token leaves the listener off. The
wizard's apply now flips `enabled = true` alongside bot_token (two config
writes, one reload).

Provisioning: re-assert Telegram-readiness idempotently in
provision-node-app.sh — a board's telegram listener only starts if the
[channels.telegram.default] block exists AND agents.default subscribes to
`telegram.default`. The template ships both; the provisioner now patches
them back in if a carried config drifted, so every board is Telegram-ready
out of the box. (Verified the patcher against the template + a drifted
config: no-op when ready, additive when not, idempotent.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 00:38:29 -07:00
Omar SobhandClaude Opus 4.8 a4d06ebddb fix(telegram): apply bot token to the real alias + in-container reload path
Verified against a live board: the open workshop board accepts a remote
config write but refuses a remote /admin/reload (only loopback is
allowed, and the gateway runs inside a container so even host→localhost
is non-loopback). And the board's telegram channel is provisioned under
alias `default`, not `main`.

- bridge configureTelegram: write channels.telegram.default.bot_token
  (was .main), and make the remote /admin/reload best-effort — never
  fail on its rejection; the board applies the reload itself.

Companion change in zeroclaw firmware (main.py reload_watcher): the
in-container supervisor polls /api/config/reload-status and triggers the
loopback /admin/reload when a change is pending. Proven E2E on the live
board — remote PUT → pending_reload → watcher reload within ~3s, channel
back up.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 00:33:51 -07:00
Omar SobhandClaude Opus 4.8 59aa2365c2 Merge feat/local-demo-phase1: local-demo landing + Phase 1 rework + live Telegram setup
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 00:24:11 -07:00
Omar SobhandClaude Opus 4.8 063116507b feat(workshop): local-demo landing + consolidated Phase 1 + live Telegram setup
Reframes the app for the local single-team demo and reworks onboarding.

Landing (local-demo chrome):
- Drop Lecture / staff sign-in / programme / stat tiles / bottom CTA.
- Hero → "an APESS agent on the edge"; CTA → "Start the workshop".
- Presenter keeps redclaw.dev + [email protected] contact.

Phase strip:
- Colour by position off the active phase (earlier=done, current=active,
  later=pending) so a stale/persisted flag can't light up a future phase
  green (fixes "phase 2 green while on phase 1").

Phase 1 (Team registration) — consolidated onboarding:
- After the board connects, reveal Say hi (moved from Phase 2), Extra
  channels (Telegram wizard), and a Voice enable/disable toggle.
- Phase 2 "Meet your node" thins to Open-your-node + Pick-your-domain;
  removed the Lock-it-down card. New session `channels {telegram, voice}`
  (persist v3, non-destructive migration).

Live Telegram apply (browser → APESS API → board ZeroClaw gateway):
- Wizard Finish pushes the bot token to the running node and restarts it:
  PUT /api/config/prop channels.telegram.main.bot_token (gateway
  auto-creates the alias + enc2-encrypts the secret) then POST
  /admin/reload (in-place subsystem reload → the Telegram channel starts).
- api: bridge configureTelegram + POST /nodes/:teamId/telegram; client
  configureTelegram(); wizard shows applying/error and only saves on
  success. Remote /admin/reload needs the board's gateway.allow_remote_admin
  (+ pairing); on a fully-open board the reload step 403s.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 00:24:05 -07:00
Omar SobhandClaude Opus 4.8 6386884842 Merge feat/central-mode-and-judge-site: judge site tag + central-mode deploy wiring
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:38:40 -07:00
Omar SobhandClaude Opus 4.8 0adeb75236 feat(federation): site tag in judge queue + central-mode deploy wiring
Judge site label: SubmissionSummary now carries `site` (from the
submissions.site column, populated in listSubmissions + upsertSubmission
and rippled through the submission:new broadcast). SubmissionList shows
it under the team name so a central judge can tell which team stack a
submission came from; hidden on single-fleet deploys where site is ''.

Central-mode deploy: the existing cloud deploy already carries
FLEET_SECRET and the instance-ingest routes are unconditional, so it IS
the central control plane — no build flag needed. Fixed the reporter's
CENTRAL_API default to the real api ORIGIN (apess-api.redclaw.dev, not
the web host + /api proxy — the reporter calls server-to-server).
Documented the wiring: deploy/.env.example + a "Central control plane"
section in deploy/README.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:34:39 -07:00
115 changed files with 7351 additions and 1460 deletions
+7
View File
@@ -32,3 +32,10 @@ deploy/workshop-llm/.env
*.njsproj
*.sln
*.sw?
# Distributable artifacts (baked token) — never commit
deploy/download/*.zip
deploy/download/*.tar*
# Local dev copy of the onboarding zip (baked token)
public/download/
+3
View File
@@ -169,6 +169,9 @@ describe('collective API', () => {
.expect(201)
const teams = await request(central).get('/teams').set('X-Access-Code', ADMIN)
expect(teams.body[0]).toMatchObject({ id: 'site-a:team-1', site: 'site-a' })
// the judge queue summary carries the site so a central judge can disambiguate
const subs = await request(central).get('/submissions').set('X-Access-Code', JUDGE)
expect(subs.body[0]).toMatchObject({ teamId: 'site-a:team-1', site: 'site-a' })
})
})
})
+122 -6
View File
@@ -4,6 +4,7 @@ import type { Store } from './db'
import { requireCode, requireAnyCode, matches } from './auth'
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
import type { NodeBridge } from './nodes'
import { readMatrixFrame } from './nodes'
import type { BoardRegistry } from './claim'
export interface AppOptions {
@@ -19,6 +20,9 @@ export interface AppOptions {
boards?: BoardRegistry
/** Shared fleet secret boards present when self-registering. */
fleetSecret?: string
/** Self-host / USB single-board mode: the API is private to one laptop with one
* board 1:1 over USB, so the board auto-binds to the team with no claim code. */
localMode?: boolean
}
const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false }
@@ -26,7 +30,7 @@ const emptyStats = { calls: 0, nominal: 0, anomalous: 0, critical: 0 }
/** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */
export function createApp(opts: AppOptions): Express {
const { store, broadcast, adminCode, judgeCode, nodes, boards, fleetSecret } = opts
const { store, broadcast, adminCode, judgeCode, nodes, boards, fleetSecret, localMode } = opts
const now = opts.now ?? (() => new Date().toISOString())
const app = express()
app.use(cors({ origin: opts.corsOrigin ?? true }))
@@ -36,6 +40,12 @@ export function createApp(opts: AppOptions): Express {
res.type('text/plain').send('ok')
})
// Runtime flags the client reads once on load (e.g. to auto-connect the board
// with no claim code in self-host/USB mode).
app.get('/mode', (_req, res) => {
res.json({ localMode: !!localMode })
})
// --- public participant sync -------------------------------------------
app.put('/teams/:id', (req, res) => {
const b = req.body ?? {}
@@ -218,18 +228,24 @@ export function createApp(opts: AppOptions): Express {
app.post('/claim', async (req, res) => {
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {}
if (typeof b.teamId !== 'string' || typeof b.code !== 'string') {
return res.status(400).json({ error: 'teamId and code are required' })
if (typeof b.teamId !== 'string') {
return res.status(400).json({ error: 'teamId is required' })
}
// LOCAL_MODE (self-host/USB): codeless auto-bind of the single local board.
const useLocal = !!localMode && b.local === true
if (!useLocal && typeof b.code !== 'string') {
return res.status(400).json({ error: 'code is required' })
}
// Code-first (board scrolls its code on the matrix, no kit picked) is the
// default; a supplied `kit` keeps the legacy sticker-claim path working.
const result =
typeof b.kit === 'string' && b.kit
const result = useLocal
? boards.claimLocal(b.teamId)
: typeof b.kit === 'string' && b.kit
? boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
: boards.claimByCode(b.code, b.teamId, Date.parse(now()))
if (!result.ok) {
if (result.reason === 'unknown') {
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' })
return res.status(404).json({ error: useLocal ? 'no board detected yet — plug it in' : 'no board found for that kit — is it powered on?' })
}
if (result.reason === 'rate_limited') {
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
@@ -281,6 +297,25 @@ export function createApp(opts: AppOptions): Express {
res.status(204).end()
})
// LOCAL_MODE: the participant's own "disconnect" — unbind this team's board and
// drop its node so it returns to the auto-connect pool (re-binds on reconnect).
// Public (private single-laptop API); no-op unless localMode.
app.post('/nodes/:teamId/disconnect', (req, res) => {
if (!localMode) return res.status(403).json({ error: 'local mode only' })
if (!boards) return res.status(503).json({ error: 'claim unavailable' })
const teamId = String(req.params.teamId)
const freed = boards.releaseByTeam(teamId)
if (nodes) nodes.remove(teamId)
const prev = store.getTeam(teamId)
if (prev) {
const team: TeamSnapshot = { ...prev, deviceConnected: false, updatedAt: now() }
store.upsertTeam(team)
broadcast({ type: 'team:update', team })
}
broadcastUnclaimed()
res.json({ teamId, released: !!freed })
})
app.post('/nodes/:teamId/prompt', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
@@ -309,6 +344,71 @@ export function createApp(opts: AppOptions): Express {
}
})
// Set the team's Telegram bot token on their node and reload it so the channel
// starts. Public + participant-scoped (like /prompt): the value is the team's
// own @BotFather token; the node's bearer stays server-side in the bridge.
app.post('/nodes/:teamId/telegram', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
if (typeof b.token !== 'string' || !b.token.trim()) {
return res.status(400).json({ error: 'token is required' })
}
try {
const ok = await nodes.configureTelegram(String(req.params.teamId), b.token.trim())
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
res.json({ ok: true })
} catch {
res.status(502).json({ error: 'could not apply the Telegram config — is your node online?' })
}
})
// Set the on-board agent's name (agents.default.identity.name) so it adopts the
// name the team chose at registration. Best-effort from the client's side.
app.post('/nodes/:teamId/identity', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
if (typeof b.name !== 'string' || !b.name.trim()) {
return res.status(400).json({ error: 'name is required' })
}
try {
const ok = await nodes.setIdentity(String(req.params.teamId), b.name.trim())
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
res.json({ ok: true })
} catch {
res.status(502).json({ error: 'could not set the agent name — is your node online?' })
}
})
// Read/write the agent's makeup ("personality") markdown files — the MAKEUP
// slide-out cards edit these. Proxies the node's /api/personality allowlist API.
const PERSONALITY_ALLOW = new Set(['SOUL.md', 'IDENTITY.md', 'USER.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', 'MEMORY.md'])
app.get('/nodes/:teamId/personality/:file', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const file = String(req.params.file)
if (!PERSONALITY_ALLOW.has(file)) return res.status(400).json({ error: 'file not editable' })
try {
const r = await nodes.getPersonality(String(req.params.teamId), file)
if (!r) return res.status(404).json({ error: 'no node registered for team' })
res.json(r)
} catch {
res.status(502).json({ error: 'could not read the file — is your node online?' })
}
})
app.put('/nodes/:teamId/personality/:file', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const file = String(req.params.file)
if (!PERSONALITY_ALLOW.has(file)) return res.status(400).json({ error: 'file not editable' })
const b = req.body ?? {}
if (typeof b.content !== 'string') return res.status(400).json({ error: 'content is required' })
try {
const ok = await nodes.putPersonality(String(req.params.teamId), file, b.content)
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
res.json({ ok: true })
} catch {
res.status(502).json({ error: 'could not save — is your node online?' })
}
})
// Public liveness for a team's board — the wizard/self-test polls this after
// a claim. Online reflects the bridge's live /health + SSE view.
app.get('/nodes/:teamId/status', (req, res) => {
@@ -319,6 +419,22 @@ export function createApp(opts: AppOptions): Express {
res.json({ teamId, url: view.url, online: view.online })
})
// Live LED-matrix mirror: the board's current framebuffer (32 hex chars) so the
// dashboard rail can show exactly what the physical 13×8 matrix is displaying.
app.get('/nodes/:teamId/matrix', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const teamId = String(req.params.teamId)
const view = nodes.list().find((n) => n.teamId === teamId)
if (!view) return res.status(404).json({ error: 'no node registered for team' })
try {
const hex = await readMatrixFrame(view)
if (!/^[0-9a-fA-F]{32}$/.test(hex)) return res.status(502).json({ error: 'bad matrix frame' })
res.json({ teamId, hex })
} catch {
res.status(502).json({ error: 'matrix read failed' })
}
})
// Participant-scoped SSE: a team watches only its own board's activity
// (the /ws hub is admin/judge only). Public, keyed by teamId.
app.get('/nodes/:teamId/events', (req, res) => {
+29
View File
@@ -59,6 +59,16 @@ export interface BoardRegistry {
claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome
/** Release a kit back to unclaimed; returns the freed teamId (or null). */
release(kitId: string): string | null
/**
* LOCAL_MODE only: bind the single local board to `teamId` with NO code. The
* API is private to one laptop and the board is 1:1 over USB, so there's
* nothing to disambiguate. Prefers a board already this team's (resume), else
* the sole unclaimed board, else the most-recent board (re-binding a stale
* claim from a fresh single-team stack). `unknown` if no board has registered.
*/
claimLocal(teamId: string): ClaimOutcome
/** Release whatever board is bound to `teamId` (the UI "disconnect"). */
releaseByTeam(teamId: string): string | null
get(kitId: string): Board | undefined
}
@@ -155,6 +165,25 @@ export function createBoardRegistry(
onChange(board)
return freed
},
claimLocal(teamId) {
const list = [...boards.values()]
if (list.length === 0) return { ok: false, reason: 'unknown' }
const target =
list.find((b) => b.claimedBy === teamId) ?? // already ours → resume
list.find((b) => b.claimedBy === null) ?? // the sole unclaimed board
list[list.length - 1] // single-team stack: (re)bind the most-recent board
const wasMine = target.claimedBy === teamId
target.claimedBy = teamId
onChange(target)
return { ok: true, board: target, resumed: wasMine }
},
releaseByTeam(teamId) {
const board = [...boards.values()].find((b) => b.claimedBy === teamId)
if (!board) return null
board.claimedBy = null
onChange(board)
return board.kitId
},
get(kitId) {
return byKit(kitId)
},
+3 -2
View File
@@ -148,7 +148,7 @@ export function openStore(path = ':memory:'): Store {
add_json=excluded.add_json, submitted_at=excluded.submitted_at, site=excluded.site
`)
const listSubsStmt = db.prepare(`
SELECT s.team_id AS teamId, s.team_name AS teamName, s.submitted_at AS submittedAt,
SELECT s.team_id AS teamId, s.team_name AS teamName, s.submitted_at AS submittedAt, s.site AS site,
EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored
FROM submissions s ORDER BY s.submitted_at
`)
@@ -219,7 +219,7 @@ export function openStore(path = ':memory:'): Store {
})
// mark the team's final phase complete if we know the team
if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId)
return { teamId: s.teamId, teamName: s.teamName, submittedAt: s.submittedAt, scored: false }
return { teamId: s.teamId, teamName: s.teamName, submittedAt: s.submittedAt, scored: false, site: s.site ?? '' }
},
listSubmissions() {
return (listSubsStmt.all() as Array<Omit<SubmissionSummary, 'scored'> & { scored: number }>).map((r) => ({
@@ -227,6 +227,7 @@ export function openStore(path = ':memory:'): Store {
teamName: r.teamName,
submittedAt: r.submittedAt,
scored: !!r.scored,
site: r.site ?? '',
}))
},
getSubmission(teamId) {
+3
View File
@@ -12,6 +12,8 @@ const ADMIN_CODE = process.env.ADMIN_CODE ?? ''
const JUDGE_CODE = process.env.JUDGE_CODE ?? ''
const FLEET_SECRET = process.env.FLEET_SECRET ?? ''
const CORS_ORIGIN = process.env.CORS_ORIGIN
// Self-host / USB mode: one private API, one board 1:1 over USB → auto-bind, no code.
const LOCAL_MODE = /^(1|true|yes)$/i.test(process.env.LOCAL_MODE ?? '')
if (!ADMIN_CODE || !JUDGE_CODE) {
console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests')
@@ -33,6 +35,7 @@ const app = createApp({
nodes,
boards,
fleetSecret: FLEET_SECRET,
localMode: LOCAL_MODE,
})
const server = http.createServer(app)
+29
View File
@@ -16,11 +16,15 @@ const store = {} as unknown as Store
describe('node bridge + /nodes routes', () => {
let events: WsEvent[]
let sent: { node: NodeRef; message: string; agent?: string }[]
let telegramCalls: { node: NodeRef; token: string }[]
let telegramThrows: boolean
let app: ReturnType<typeof createApp>
beforeEach(() => {
events = []
sent = []
telegramCalls = []
telegramThrows = false
const nodes = createNodeBridge({
broadcast: (e) => events.push(e),
ping: async () => true, // pretend the node is online
@@ -31,11 +35,18 @@ describe('node bridge + /nodes routes', () => {
sent.push({ node, message, agent })
return `echo: ${message}`
},
setTelegram: async (node, token) => {
if (telegramThrows) throw new Error('node reload failed (403)')
telegramCalls.push({ node, token })
},
subscribe: () => () => {}, // no live SSE in the unit test
})
app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes })
})
const registerNode = () =>
request(app).post('/nodes').set('x-access-code', ADMIN).send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
it('registers a node (admin only) and never leaks the token', async () => {
await request(app).post('/nodes').send({ teamId: 't1', url: 'http://n', token: 'zc_secret' }).expect(401)
@@ -96,6 +107,24 @@ describe('node bridge + /nodes routes', () => {
expect(sent.at(-1)?.agent).toBe('cloud')
})
it('applies a Telegram token to a registered node (public, token-scoped)', async () => {
await registerNode().expect(201)
const res = await request(app).post('/nodes/t1/telegram').send({ token: 'bot-123' }).expect(200)
expect(res.body).toEqual({ ok: true })
expect(telegramCalls).toEqual([{ node: { teamId: 't1', url: 'http://n', token: 'zc_secret' }, token: 'bot-123' }])
})
it('400s a missing token, 404s an unregistered team', async () => {
await request(app).post('/nodes/t1/telegram').send({ token: '' }).expect(400)
await request(app).post('/nodes/nope/telegram').send({ token: 'bot-123' }).expect(404)
})
it('502s when the node rejects the config/reload', async () => {
await registerNode().expect(201)
telegramThrows = true
await request(app).post('/nodes/t1/telegram').send({ token: 'bot-123' }).expect(502)
})
it('503s when no bridge is configured', async () => {
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503)
+44
View File
@@ -26,6 +26,50 @@ function setup(pingUp = true) {
const statusEvents = (feed: WsEvent[]) => feed.filter((e) => e.type === 'node:status')
describe('createNodeBridge — configureTelegram', () => {
it('returns false when the team has no registered node (no apply attempted)', async () => {
let called = false
const bridge = createNodeBridge({
broadcast: () => {},
ping: async () => true,
subscribe: () => () => {},
setTelegram: async () => {
called = true
},
})
expect(await bridge.configureTelegram('nobody', 'tok')).toBe(false)
expect(called).toBe(false)
})
it('applies the token to a registered node', async () => {
const applied: Array<{ url: string; token: string }> = []
const bridge = createNodeBridge({
broadcast: () => {},
ping: async () => true,
subscribe: () => () => {},
setTelegram: async (n, token) => {
applied.push({ url: n.url, token })
},
})
await bridge.register({ teamId: 't1', url: 'http://b', token: 'zc_secret' })
expect(await bridge.configureTelegram('t1', 'bot-token')).toBe(true)
expect(applied).toEqual([{ url: 'http://b', token: 'bot-token' }])
})
it('propagates a node rejection as a throw', async () => {
const bridge = createNodeBridge({
broadcast: () => {},
ping: async () => true,
subscribe: () => () => {},
setTelegram: async () => {
throw new Error('node reload failed (403)')
},
})
await bridge.register({ teamId: 't1', url: 'http://b', token: 'zc_secret' })
await expect(bridge.configureTelegram('t1', 'bot-token')).rejects.toThrow(/reload failed/)
})
})
const flash: WsEvent = { type: 'node:activity', teamId: 't1', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' }
describe('createNodeBridge — per-team activity', () => {
+27
View File
@@ -83,6 +83,33 @@ describe('mapNodeEvent — ZeroClaw /api/events → WsEvent', () => {
expect(ev).toMatchObject({ type: 'node:activity', kind: 'response' })
})
it('surfaces a tool_call_result output as the response text (the real answer)', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'i2c_scan', output: 'No I2C devices responded on the bus.', error_reason: null },
event: { action: 'complete', category: 'tool', outcome: 'success' },
})
expect(ev).toMatchObject({ type: 'node:activity', kind: 'response', label: 'No I2C devices responded on the bus.' })
})
it('falls back to a tool ✓ marker when a tool result carries no output', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'matrix_text', output: '' },
event: { outcome: 'success' },
})
expect(ev).toMatchObject({ kind: 'response', label: 'matrix_text ✓' })
})
it('maps a failed tool result to an error activity', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'i2c_scan', output: 'bridge unreachable', error_reason: 'timeout' },
event: { outcome: 'failure' },
})
expect(ev).toMatchObject({ kind: 'error', label: 'bridge unreachable' })
})
it('ignores noisy/internal events (llm_request, plain notes, non-objects)', () => {
expect(mapNodeEvent('t1', { type: 'llm_request' })).toBeNull()
expect(mapNodeEvent('t1', { message: 'No sandbox backend available, using application-layer security' })).toBeNull()
+189
View File
@@ -1,3 +1,4 @@
import net from 'node:net'
import type { WsEvent, NodeActivityKind } from './types'
/** A team's ZeroClaw node: gateway URL + its server-side bearer token. */
@@ -7,6 +8,36 @@ export interface NodeRef {
token: string
}
/**
* Pull the board's CURRENT LED-matrix framebuffer for a pixel-perfect mirror.
* The matrix responder exposes it over the :9999 line-protocol relay (published
* on the board, same host as the gateway) via the `matrixget` command, which
* returns 32 hex chars (4×uint32, MSB-first; first 104 bits = the real pixels).
*/
export function readMatrixFrame(node: Pick<NodeRef, 'url'>, timeoutMs = 1500): Promise<string> {
const host = new URL(node.url).hostname
return new Promise((resolve, reject) => {
const sock = net.createConnection({ host, port: 9999 })
let buf = ''
let settled = false
const finish = (err?: Error, val?: string) => {
if (settled) return
settled = true
sock.destroy()
err ? reject(err) : resolve(val as string)
}
sock.setTimeout(timeoutMs)
sock.on('connect', () => sock.write('matrixget\n'))
sock.on('data', (d) => {
buf += d.toString()
const nl = buf.indexOf('\n')
if (nl >= 0) finish(undefined, buf.slice(0, nl).trim())
})
sock.on('timeout', () => finish(new Error('matrix relay timeout')))
sock.on('error', (e) => finish(e))
})
}
export interface NodeRegistry {
register(ref: NodeRef): void
get(teamId: string): NodeRef | undefined
@@ -79,6 +110,20 @@ export function mapNodeEvent(teamId: string, raw: unknown): WsEvent | null {
// 2) Structured log lines carry a `message`.
const message = str(e.message)
if (message) {
// A tool's actual RESULT — the real reply. The observability `agent_end`
// event is slow and content-free (the native tool path leaves the final
// text empty), so this `tool_call_result` line is where the answer lives:
// e.g. i2c_scan → "No I2C devices responded on the bus." Surface it as the
// agent's response so the chat shows the outcome, not just "Agent finished".
if (message === 'tool_call_result') {
const attrs = e.attributes && typeof e.attributes === 'object' ? (e.attributes as Record<string, unknown>) : {}
const ev = e.event && typeof e.event === 'object' ? (e.event as Record<string, unknown>) : {}
const output = str(attrs.output).trim()
const tool = str(attrs.tool) || 'tool'
const failed = str(ev.outcome).toLowerCase() === 'failure' || str(attrs.error_reason).length > 0
if (failed) return activity('error', output || `${tool} failed`)
return activity('response', output || `${tool} ✓`)
}
if (/compiled and flashed/i.test(message)) {
const addr = message.match(/0x[0-9A-Fa-f]+/)?.[0]
return activity('flash', addr ? `Flashed to ${addr}` : 'Flashed to the MCU')
@@ -139,6 +184,107 @@ export async function promptAndWait(node: NodeRef, message: string, agent = 'def
return (body.response ?? '').trim()
}
/**
* Set the team's Telegram bot token on their node so the channel picks it up.
* Writes `channels.telegram.default.bot_token` via PUT /api/config/prop — the
* gateway auto-creates the alias if absent (`ensure_map_key_for_path`) and
* enc2-encrypts the secret on disk. That write also flips the node's
* `pending_reload` flag.
*
* The reload that actually starts the channel is done ON the board, not here:
* the gateway runs inside a container, so a remote POST /admin/reload is refused
* (only loopback is allowed on an open board). The node's in-container watcher
* (`reload_watcher` in the App-Lab app) sees `pending_reload` and triggers the
* loopback reload within a few seconds. We still fire a best-effort remote
* reload for paired boards that permit it, but never fail on its rejection.
*/
export async function configureTelegram(node: NodeRef, token: string): Promise<void> {
const auth = { authorization: `Bearer ${node.token}` }
const setProp = async (path: string, value: unknown) => {
const res = await fetch(`${node.url}/api/config/prop`, {
method: 'PUT',
headers: { ...auth, 'content-type': 'application/json' },
body: JSON.stringify({ path, value, comment: 'set via APESS onboarding' }),
})
if (!res.ok) throw new Error(`config write ${path} failed (${res.status})`)
}
// The template seeds telegram.default disabled with an empty token — set the
// token AND flip enabled, or the channel never starts listening.
await setProp('channels.telegram.default.bot_token', token)
await setProp('channels.telegram.default.enabled', true)
// Best-effort: instant reload on boards that allow remote admin; the board's
// own in-container watcher applies it otherwise. Never throw on a refused
// remote reload.
try {
await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
} catch {
/* watcher will apply it */
}
}
/**
* Set the on-board agent's display name (`agents.default.identity.name`) + reload,
* so the agent adopts the name the team chose. Same config-prop + watcher path as
* {@link configureTelegram} — the gateway auto-creates the key if absent.
*/
export async function configureIdentity(node: NodeRef, name: string): Promise<void> {
const auth = { authorization: `Bearer ${node.token}` }
const res = await fetch(`${node.url}/api/config/prop`, {
method: 'PUT',
headers: { ...auth, 'content-type': 'application/json' },
body: JSON.stringify({ path: 'agents.default.identity.name', value: name, comment: 'set via APESS onboarding' }),
})
if (!res.ok) throw new Error(`identity write failed (${res.status})`)
try {
await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
} catch {
/* watcher will apply it */
}
}
// The agent's editable "personality" (makeup) markdown files — the gateway
// allowlist. Used to guard which files the participant can edit from the UI.
export const PERSONALITY_FILES = [
'SOUL.md',
'IDENTITY.md',
'USER.md',
'AGENTS.md',
'TOOLS.md',
'HEARTBEAT.md',
'MEMORY.md',
] as const
/** Read one of the agent's makeup files from the node (GET /api/personality/{file}). */
export async function readPersonality(node: NodeRef, file: string): Promise<{ content: string; exists: boolean }> {
const res = await fetch(`${node.url}/api/personality/${encodeURIComponent(file)}?agent=default`, {
headers: { authorization: `Bearer ${node.token}` },
})
if (!res.ok) throw new Error(`personality read failed (${res.status})`)
const j = (await res.json()) as { content?: string; exists?: boolean }
return { content: j.content ?? '', exists: !!j.exists }
}
/**
* Overwrite one of the agent's makeup files (PUT /api/personality/{file}) and
* reload so the agent re-reads it. The agent loads these each session anyway, but
* we fire a best-effort reload to apply it right away (the in-container watcher
* applies it otherwise). Same auth path as {@link configureTelegram}.
*/
export async function writePersonality(node: NodeRef, file: string, content: string): Promise<void> {
const auth = { authorization: `Bearer ${node.token}` }
const res = await fetch(`${node.url}/api/personality/${encodeURIComponent(file)}?agent=default`, {
method: 'PUT',
headers: { ...auth, 'content-type': 'application/json' },
body: JSON.stringify({ content }),
})
if (!res.ok) throw new Error(`personality write failed (${res.status})`)
try {
await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
} catch {
/* watcher will apply it */
}
}
export interface SubscribeOptions {
/** Aborts the whole reconnect loop when fired. */
signal?: AbortSignal
@@ -238,6 +384,18 @@ export interface NodeBridge {
/** Say-hi: prompt the node and return its reply text (blocking). `null` if the
* team has no registered node. Non-flash use only (the greeting). */
sayHi(teamId: string, message: string, agent?: string): Promise<string | null>
/** Write the team's Telegram bot token to their node + reload it so the channel
* starts. Resolves `true` on success, `false` if no node is registered;
* throws if the node rejects the config write or reload. */
configureTelegram(teamId: string, token: string): Promise<boolean>
/** Set the on-board agent's name so it adopts it. `true` on success, `false` if
* no node is registered; throws if the node rejects the write. */
setIdentity(teamId: string, name: string): Promise<boolean>
/** Read one of the agent's makeup ("personality") files. `null` if no node. */
getPersonality(teamId: string, file: string): Promise<{ content: string; exists: boolean } | null>
/** Overwrite one of the agent's makeup files + reload. `false` if no node;
* throws if the node rejects the write. */
putPersonality(teamId: string, file: string, content: string): Promise<boolean>
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
stopAll(): void
@@ -250,6 +408,10 @@ export interface NodeBridgeDeps {
ping?: (n: NodeRef) => Promise<boolean>
send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string>
setTelegram?: (n: NodeRef, token: string) => Promise<void>
setIdentity?: (n: NodeRef, name: string) => Promise<void>
readPersonality?: (n: NodeRef, file: string) => Promise<{ content: string; exists: boolean }>
writePersonality?: (n: NodeRef, file: string, content: string) => Promise<void>
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
}
@@ -264,6 +426,10 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
const ping = deps.ping ?? pingNode
const send = deps.send ?? sendPrompt
const sendAndWait = deps.sendAndWait ?? promptAndWait
const setTelegram = deps.setTelegram ?? configureTelegram
const applyIdentity = deps.setIdentity ?? configureIdentity
const doReadPersonality = deps.readPersonality ?? readPersonality
const doWritePersonality = deps.writePersonality ?? writePersonality
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
const online = new Map<string, boolean>()
const stops = new Map<string, () => void>()
@@ -313,6 +479,29 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
if (!node) return null
return sendAndWait(node, message, agent)
},
async configureTelegram(teamId, token) {
const node = registry.get(teamId)
if (!node) return false
await setTelegram(node, token)
return true
},
async setIdentity(teamId, name) {
const node = registry.get(teamId)
if (!node) return false
await applyIdentity(node, name)
return true
},
async getPersonality(teamId, file) {
const node = registry.get(teamId)
if (!node) return null
return doReadPersonality(node, file)
},
async putPersonality(teamId, file, content) {
const node = registry.get(teamId)
if (!node) return false
await doWritePersonality(node, file, content)
return true
},
onTeamActivity(teamId, listener) {
let set = teamListeners.get(teamId)
if (!set) {
+3
View File
@@ -57,6 +57,9 @@ export interface SubmissionSummary {
teamName: string
submittedAt: string
scored: boolean
/** Federation tag — the instance (site) this submission came from. '' on a
* single-fleet deploy; lets a central judge see which team stack it's from. */
site?: string
}
export interface ScoreInput {
+13
View File
@@ -0,0 +1,13 @@
# Copy to .env (next to docker-compose.yml). Do NOT commit .env.
#
# This is the CLOUD / control-plane deploy (apess.redclaw.dev + apess-api.redclaw.dev
# behind traefik). It doubles as the CENTRAL control plane in a federated event:
# per-team local stacks (deploy/lan, --profile federated) report UP to it via the
# reporter sidecar, and their CENTRAL_API must point at this api's origin
# (https://apess-api.redclaw.dev) with the SAME FLEET_SECRET set here.
ADMIN_CODE=adm-xxxxxxxx
JUDGE_CODE=jdg-xxxxxxxx
# Shared federation secret: boards present it to /nodes/self-register AND local
# instances present it to /instances/register|heartbeat. Must match every
# board's apess-node.env and every reporter's FLEET_SECRET.
FLEET_SECRET=change-me
+24
View File
@@ -85,6 +85,30 @@ ssh [email protected] "cd ~/projects/apress && docker compose -f deploy/dock
# Remove DNS records if needed.
```
## Central control plane (federated events)
For a multi-team event where each team runs its **own** local stack (`deploy/lan`,
with a board attached), this cloud deploy doubles as the **central control
plane** — the fleet dashboard + centralized judging. No separate build or flag:
the instance-ingest routes (`/instances/register`, `/instances/:id/heartbeat`,
`GET /instances`) and the `site`-tagged `PUT /teams/:id` / `POST /submissions`
are always present; the `Admin` dashboard switches to the grouped **fleet view**
automatically once instances phone home.
To wire a local team stack up to it:
1. This deploy must have `FLEET_SECRET` set (it already gates board self-register).
2. On each team's `deploy/lan` box, set in `.env`: a unique `SITE_ID`, the same
`FLEET_SECRET`, and `CENTRAL_API=https://apess-api.redclaw.dev` (the **api**
origin — the reporter calls it server-to-server, so no `/api` proxy).
3. Bring the team stack up with the reporter: `docker compose --env-file .env
-f docker-compose.yml --profile federated up -d --build`.
The reporter mirrors that instance's teams/submissions up (namespaced by
`SITE_ID`), and it appears in `/admin`'s fleet view with online/offline from its
heartbeat. Central is **observe-only** — it never touches a board (it can't reach
the NAT'd boards; only each local stack drives its own board).
## Why this lives on gw-03
- 104 GB free disk, 16 GB Intel RAM, idle CPU
+4
View File
@@ -8,6 +8,10 @@ services:
image: apess-web:latest
container_name: apess-web
restart: unless-stopped
volumes:
# Serve /download/apess-onboard.zip (the App Lab onboarding app). Drop the
# zip into deploy/download/ — no image rebuild. The zip holds a baked token.
- ./download:/usr/share/nginx/download:ro
networks:
- clawbooks-net
# Routing is defined in deploy/traefik/apess.yml (file provider) to avoid
+26
View File
@@ -0,0 +1,26 @@
# Download directory
Drop distributable artifacts here — they're served at `/download/<file>` by both
the prod (`deploy/docker-compose.yml`) and LAN (`deploy/lan/`) web containers via a
read-only volume mount. No image rebuild needed; the container picks up new files
immediately.
## The App Lab onboarding app
```sh
# 1. build the zip (bakes in the cloud token)
export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-…
./deploy/uno-q/package-onboard-app.sh
# 2. place it here for download
cp deploy/uno-q/dist/apess-onboard.zip deploy/download/
# 3. students fetch it (then App Lab → "Import an app" → Run)
# https://apess.redclaw.dev/download/apess-onboard.zip
```
## ⚠️ Secret
`apess-onboard.zip` contains a **baked cloud token**. Never commit it (this dir's
`*.zip` is gitignored). Treat the download URL as a secret — serve it on the
workshop network, or behind the workshop's access, not as a public link.
+4 -2
View File
@@ -13,5 +13,7 @@ WEB_PORT=80
# SITE_ID must be unique per team/instance — it namespaces every id at central.
SITE_ID=team-01
SITE_NAME=Team 01
# Central control-plane API base (its /api origin).
CENTRAL_API=https://apess.redclaw.dev/api
# Central control-plane API ORIGIN (the api host itself, not the web host — the
# reporter calls it server-to-server, so no /api proxy). Must run with the same
# FLEET_SECRET as below.
CENTRAL_API=https://apess-api.redclaw.dev
+8
View File
@@ -0,0 +1,8 @@
@echo off
REM connect-board.bat - double-click launcher for connect-board.ps1 on Windows.
REM Runs the PowerShell script with the execution policy bypassed for this run
REM only (nothing is changed system-wide). Pass -Watch to keep re-attaching:
REM connect-board.bat -Watch
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0connect-board.ps1" %*
echo.
pause
+105
View File
@@ -0,0 +1,105 @@
<#
.SYNOPSIS
Attach the USB Uno Q board to your LOCAL self-host stack (Windows 11).
.DESCRIPTION
PowerShell twin of connect-board.sh. Runs adb ON the Windows host so Docker
Desktop's host.docker.internal reaches the forwarded port. Forwards the tunnels
and registers the board with the containerized API; in LOCAL_MODE the API
auto-binds the board to your team in the browser — no claim code.
.EXAMPLE
.\connect-board.ps1 # attach once
.\connect-board.ps1 -Watch # re-attach on every (re)connect (leave running)
.NOTES
Env overrides: WEB_URL, FLEET_SECRET, KIT_ID, NODE_URL, SERIAL
If Windows blocks the script, run it as:
powershell -NoProfile -ExecutionPolicy Bypass -File .\connect-board.ps1
(or just double-click connect-board.bat).
#>
param([switch]$Watch)
$ErrorActionPreference = 'Stop'
$Web = if ($env:WEB_URL) { $env:WEB_URL } else { 'http://localhost:8090' }
$Api = "$Web/api"
$Secret = if ($env:FLEET_SECRET) { $env:FLEET_SECRET } else { 'apess2026' }
$KitId = if ($env:KIT_ID) { $env:KIT_ID } else { 'crimson-node' }
# How the API *container* reaches the board: adb binds the Windows host loopback,
# and Docker Desktop maps host.docker.internal to the Windows host.
$NodeUrl = if ($env:NODE_URL) { $env:NODE_URL } else { 'http://host.docker.internal:8080' }
$Ports = @(8080, 9999)
function Log($m) { Write-Host "[connect] $m" -ForegroundColor Cyan }
function Ok($m) { Write-Host " [OK] $m" -ForegroundColor Green }
function Warn($m) { Write-Host " [!] $m" -ForegroundColor Yellow }
if (-not (Get-Command adb -ErrorAction SilentlyContinue)) {
Write-Host "connect-board: adb not found on PATH. Install Android platform-tools and reopen the terminal." -ForegroundColor Red
exit 127
}
function Get-Serial {
if ($env:SERIAL) { return $env:SERIAL }
foreach ($line in (& adb devices)) {
if ($line -match '^(\S+)\s+device$') { return $Matches[1] }
}
return $null
}
function Connect-Once {
$serial = Get-Serial
if (-not $serial) { Warn 'no board attached over USB'; return $false }
Ok "board $serial attached"
# 1 - forward tunnels (they vanish on re-plug)
$existing = (& adb -s $serial forward --list) -join "`n"
foreach ($p in $Ports) {
if ($existing -notmatch "tcp:$p") { & adb -s $serial forward "tcp:$p" "tcp:$p" | Out-Null }
}
Ok "tunnels forwarded ($($Ports -join ' '))"
# 2 - wait for the board daemon (App Lab app auto-starts on boot)
$n = 0
while ($true) {
try { Invoke-WebRequest -Uri 'http://127.0.0.1:8080/health' -TimeoutSec 2 -UseBasicParsing | Out-Null; break }
catch {
$n++
if ($n -gt 90) { Warn 'board daemon never came up'; return $false }
Start-Sleep -Seconds 2
}
}
Ok 'board daemon healthy'
# 3 - register with the LOCAL stack (LOCAL_MODE auto-binds it in the browser)
$body = @{ kitId = $KitId; claimCode = 'local'; url = $NodeUrl; token = 'open-lan' } | ConvertTo-Json -Compress
try {
$r = Invoke-RestMethod -Uri "$Api/nodes/self-register" -Method Post `
-Headers @{ 'x-fleet-secret' = $Secret; 'content-type' = 'application/json' } `
-Body $body -TimeoutSec 5
if ($r.url) {
Ok 'registered with the local stack'
Log 'attached — it auto-connects in the browser (no code needed)'
return $true
}
Warn "register returned an unexpected response: $($r | ConvertTo-Json -Compress)"
return $false
} catch {
Warn "register failed — is the stack up? ($Web) · $($_.Exception.Message)"
return $false
}
}
if ($Watch) {
Log 'watching for the board — will attach on every (re)connect (Ctrl-C to stop)'
while ($true) {
& adb wait-for-device | Out-Null
Start-Sleep -Seconds 3 # let Linux + the App Lab app finish booting
if (-not (Connect-Once)) { Warn 'attach incomplete; will retry on next reconnect' }
while (Get-Serial) { Start-Sleep -Seconds 2 }
Log 'board disconnected — waiting for re-plug'
}
} else {
[void](Connect-Once)
}
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# connect-board.sh — attach the USB board to your LOCAL self-host stack.
#
# The stack runs in Docker on this laptop; the board is on USB, reached over adb.
# This forwards the tunnels and registers the board with the containerized API
# (which reaches it at host.docker.internal). In LOCAL_MODE the API auto-binds the
# board to your team in the browser — no claim code. Run with --watch to re-attach
# automatically on every (re)connect.
#
# Usage:
# ./connect-board.sh # attach once
# ./connect-board.sh --watch # attach on every (re)connect (leave it running)
#
# Env: SERIAL (auto-detected if unset) · WEB_URL (default http://localhost:8090)
# FLEET_SECRET (default apess2026) · KIT_ID · NODE_URL
set -uo pipefail
WEB="${WEB_URL:-http://localhost:8090}"
API="$WEB/api"
FLEET_SECRET="${FLEET_SECRET:-apess2026}"
KIT_ID="${KIT_ID:-crimson-node}"
# How the API *container* reaches the board (adb binds host loopback; the API is
# in Docker, so it uses the host gateway alias — see docker-compose extra_hosts).
NODE_URL="${NODE_URL:-http://host.docker.internal:8080}"
PORTS=(8080 9999)
ADB="$(command -v adb || true)"
for c in /opt/homebrew/bin/adb /usr/local/bin/adb "$HOME/Library/Android/sdk/platform-tools/adb"; do
[ -n "$ADB" ] && break
[ -x "$c" ] && ADB="$c"
done
[ -n "$ADB" ] || { echo "connect-board: adb not found in PATH"; exit 127; }
log() { printf '\033[36m[connect]\033[0m %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
# First attached device unless SERIAL is pinned.
detect_serial() {
[ -n "${SERIAL:-}" ] && { echo "$SERIAL"; return; }
"$ADB" devices | awk '/\tdevice$/{print $1; exit}'
}
connect_once() {
local serial; serial="$(detect_serial)"
[ -n "$serial" ] || { warn "no board attached over USB"; return 1; }
ok "board $serial attached"
# 1 · forward tunnels (vanish on re-plug)
for p in "${PORTS[@]}"; do
"$ADB" -s "$serial" forward --list 2>/dev/null | grep -q "tcp:$p" \
|| "$ADB" -s "$serial" forward "tcp:$p" "tcp:$p" >/dev/null
done
ok "tunnels forwarded (${PORTS[*]})"
# 2 · wait for the board daemon (App Lab app auto-starts on boot)
local n=0
until curl -s -m2 http://127.0.0.1:8080/health -o /dev/null 2>/dev/null; do
n=$((n + 1)); [ "$n" -gt 90 ] && { warn "board daemon never came up"; return 1; }
sleep 2
done
ok "board daemon healthy"
# 3 · register with the LOCAL stack (LOCAL_MODE auto-binds it in the browser).
# The claim code is irrelevant in local mode but the endpoint wants one.
local r
r="$(curl -s -m5 -X POST "$API/nodes/self-register" \
-H "x-fleet-secret: $FLEET_SECRET" -H 'content-type: application/json' \
-d "{\"kitId\":\"$KIT_ID\",\"claimCode\":\"local\",\"url\":\"$NODE_URL\",\"token\":\"open-lan\"}" 2>/dev/null)"
if echo "$r" | grep -q '"url"'; then
ok "registered with the local stack"
log "attached — it auto-connects in the browser (no code needed)"
else
warn "register failed — is the stack up? ($WEB) · $r"
return 1
fi
}
watch_loop() {
log "watching for the board — will attach on every (re)connect (Ctrl-C to stop)"
while true; do
"$ADB" wait-for-device
sleep 3 # let Linux + the App Lab app finish booting
connect_once || warn "attach incomplete; will retry on next reconnect"
# wait until it disconnects
while [ -n "$(detect_serial)" ]; do sleep 2; done
log "board disconnected — waiting for re-plug"
done
}
case "${1:-}" in
--watch | -w) watch_loop ;;
*) connect_once ;;
esac
+12
View File
@@ -27,6 +27,9 @@ services:
- '${WEB_PORT:-80}:80' # WEB_PORT=8080 if the box can't bind :80
volumes:
- ./nginx.lan.conf:/etc/nginx/conf.d/default.conf:ro
# Serve /download/apess-onboard.zip — drop the packaged app zip into
# deploy/download/ (shared with the prod deploy). Holds a baked token.
- ../download:/usr/share/nginx/download:ro
depends_on:
- apess-api
networks: [apess-lan]
@@ -44,9 +47,18 @@ services:
# Must match the FLEET_SECRET baked into each board's apess-node.env.
FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET}
DB_PATH: /data/apess.db
# Self-host / USB single-board mode: the board auto-binds to the team with
# no claim code (one private API, one board 1:1 over USB).
LOCAL_MODE: ${LOCAL_MODE:-true}
# Same-origin via the /api proxy → no CORS needed (API default is permissive).
volumes:
- apess-lan-data:/data
# USB self-host: the board is attached to THIS laptop and reached over adb
# (`adb forward tcp:8080/tcp:9999`), which binds host loopback. The API runs
# in a container, so it reaches the board at host.docker.internal — mapped to
# the host gateway here (built-in on Docker Desktop; required on Linux).
extra_hosts:
- 'host.docker.internal:host-gateway'
# Not published: boards + browsers reach the API through the web's /api proxy.
networks: [apess-lan]
+11
View File
@@ -52,6 +52,17 @@ server {
return 200 "ok\n";
}
# Downloadable artifacts (the App Lab onboarding zip) — mounted dir, so you drop
# the file in without rebuilding (compose: ../download:/usr/share/nginx/download).
# apess-onboard.zip carries a baked cloud token; it's on the isolated workshop
# LAN, but treat the path as a secret. autoindex off = not browsable.
location /download/ {
alias /usr/share/nginx/download/;
autoindex off;
add_header Cache-Control "no-store";
try_files $uri =404;
}
# SPA fallback (client-side routes: /workshop, /admin, /judge, …)
location / {
try_files $uri $uri/ /index.html;
+12
View File
@@ -23,6 +23,18 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Downloadable artifacts (the App Lab onboarding zip). Served from a MOUNTED
# dir so you drop files in without rebuilding the image (see docker-compose:
# ./download:/usr/share/nginx/download:ro). NOTE: apess-onboard.zip carries a
# baked cloud token — treat the URL as a secret (share on the workshop network,
# not publicly). autoindex off so the directory can't be browsed.
location /download/ {
alias /usr/share/nginx/download/;
autoindex off;
add_header Cache-Control "no-store";
try_files $uri =404;
}
location = /healthz {
access_log off;
return 200 "ok\n";
+149
View File
@@ -0,0 +1,149 @@
# Linear actuator — bring-up & run-book
The workshop board can drive a **linear actuator** (stepper on a lead screw/belt) as the
**"Adapt"** half of the Sense→Forecast→Adapt loop: the ADXL355 senses, the agent decides, the
actuator moves. This is open-loop motion with a **calibrated software safety envelope** so it can
never overrun its ends.
> **Read this before powering an actuator-equipped board.** The one rule that bites: a restart
> resets the position zero (see [Operating rules](#operating-rules)).
---
## Hardware
| Part | Detail |
|---|---|
| Driver | **TB6600 / PB6600** (PUL / DIR / ENA, optically isolated). No feedback, no stall detection. |
| Wiring | **PUL → digital pin 4**, **DIR → digital pin 5** (common-cathode: signal `-` pins to GND). ENA left free (driver enabled). |
| Motion | `dir=1` = **into the rail** (away from the zero end) · `dir=0` = **back toward zero**. |
| Speed | ~830 steps/s (600 µs half-period), moderate — safe for most drivers without missed steps. |
| Sensor (same board) | ADXL355 @ `0x1d` behind a **PCA9548A mux** (`0x70`) on channel 0. Unrelated bus (I²C on SDA/SCL); doesn't compete with D4/D5. |
The **TB6600 has no way to sense position or the ends** — that's why travel is bounded in
firmware, not hardware. If you ever add physical limit switches, that becomes the robust upgrade;
until then the envelope below is the guard.
---
## How it's controlled
The resident MCU sketch (`firmware/zeroclaw-node/sketch/sketch.ino`) runs the pulse train and
enforces the limits. Two ways to reach it:
**Agent tools** (cloud brain, within limits):
- `stepper(steps, dir)` — move `steps` (1–4000) in direction `dir` (1 into rail / 0 toward zero).
- `stepper_status()` — read position + limit without moving.
**Relay commands** (`:9999`, for setup/calibration from the host — `nc`/socket):
- `step <count> <dir>` · `zero` · `pos` · `setmax <n>`
- `osc <amp_steps> <freq_cHz> <cycles>` — open-loop sinusoidal excitation (shaker mode)
- `oscm <amp> <freq_cHz> <cycles>` — oscillate while measuring the ADXL355 (per-axis p-p mg)
- `accel` — one ADXL355 sample (mg); `dvf <gain> <secs>` — closed-loop damping (below)
### Direct Velocity Feedback (`dvf`) — active damping
Closes the loop: ADXL355 X-accel @ ~250 Hz → leaky-integrated velocity → stepper
commands `-g·v` (velocity feedback adds damping, c → c+g). Envelope-clamped and
slew-limited. **Runs must be ≤ 8 s** — the RouterBridge RPC times out at 10 s.
Bench-measured gain range:
| gain | behaviour |
|---|---|
| 500 | gentle |
| **1000–2000** | **authoritative and stable — recommended** |
| 4000 | **UNSTABLE** — self-excites off its own step-vibration (velPk 30→1442, 26k steps); the soft-limit envelope catches the runaway |
Actuator FRF note: open-loop amplitude rolls off with frequency — full ~10 mm
holds to ~2–3 Hz, only a few mm by 10 Hz.
**Shaker-table campaign findings (2 Hz base excitation):**
- The control law is **position-target DVF**: carriage position target =
`center − K·v` (reaction force ∝ −v = true damping). A velocity-command law
(carriage velocity ∝ v) is force ∝ −a = *added mass* — no dissipation; it
amplified the response at every gain/sign. Don't regress to it.
- **Safe reference setting: `dvf 30 8`** with the slew limit at 8 steps/tick
(~2000 steps/s). Ran 2 min continuous, silent, self-centering, no runaway.
Slew 25 grinds the motor (lost steps → position corrupted).
- **Sensor placement is the binding constraint:** a deck-mounted sensor near
the rail reads the carriage's own motion (~856 mg open-loop) louder than the
structure sway (~435 mg), so closed-loop damping can't be scored (or cleanly
fed back). Mount the feedback sensor at the structure's max-sway point (tower
top), ideally a second ADXL355 on mux channel 1.
- `listen <secs>` (≤8 s) = passive baseline/ring-down instrument. Old numbers:
tap tests gave stable-looking g up to ~2000, but that predates the shaker
campaign — trust the shaker findings.
Every move is **clamped to `[0, stepMax]`** and the reply reports position, e.g. `pos=1234
max=9635`, ending in `LIMIT` if it hit the soft limit.
---
## Calibrated envelope (this actuator)
```
0 ─────────────────────────────── 9635 ····· 9685
zero (right end) armed safe max hard end
↑ 50-step margin ↑
```
`stepMax = 9635` is **baked into the sketch** (armed on every boot). Hard end measured at ~9685
steps; armed 50 short so a move never reaches the physical stop.
---
## Daily bring-up (safe sequence)
1. **Wire the actuator first, then start the app.** (Touching the bus on a running board resets the
MCU and crashes the app container — wire cold.)
2. Bring the app up (`arduino-app-cli app start …`); confirm `pos` reports `max=9635`.
3. **Home it:** manually park the carriage at the **right end**, then send **`zero`**.
- Now `pos=0` matches reality; the envelope is already armed. Ready.
That's it — the agent can now drive it safely.
---
## Operating rules
- **Park at the right end and `zero` BEFORE any restart.** A container `app restart` **resets the
MCU position to 0** while the carriage stays where it is. If it was parked anywhere but the right
end, firmware `pos` and reality now disagree — and a `dir=1` move would drive into the far stop.
The **envelope (max) survives** a restart; the **zero does not**.
- **Don't stall it.** Open-loop means a stall against a stop **loses steps**, so the zero drifts.
The soft limit exists precisely to avoid this — keep it armed.
- **One session = one home.** Re-`zero` at the start of each session (there's no home switch).
### Recovery — firmware/reality mismatch
If a restart left `pos=0` but the carriage isn't at the right end:
1. `setmax -1` — disarm the clamp temporarily.
2. Jog **`dir=0`** in bursts back to the **right end** (watch it; stop at the end).
3. `zero`, then `setmax 9635` to re-arm.
---
## Calibrating a *different* actuator
If the rail, motor, or TB6600 microstep DIP changes, re-measure:
1. Park at the right end → `zero`.
2. Jog `dir=1` toward the far end — coarse (`step 200 1`) then fine (`step 10 1`) as it nears —
watching. The firmware sums position for you; read it with `pos`.
3. Stop a hair short of the hard stop. Take that `pos`, subtract a ~50-step margin → that's the max.
4. Bake it: set `long stepMax = <value>;` in the sketch and reflash (push sketch → `app restart`,
~50s = a real recompile+flash).
---
## Rebuilding the binary (when the agent tools change)
The `stepper` / `stepper_status` tools live in the ZeroClaw binary
(`crates/zeroclaw-hardware/src/peripherals/uno_q_bridge.rs`). Cross-build for the board:
```sh
cd <zeroclaw>
cargo zigbuild --target aarch64-unknown-linux-gnu --profile release-fast --features hardware --bin zeroclaw
```
`cargo-zigbuild` + zig is the working cross path on macOS (the `aarch64-linux-gnu-gcc` linker isn't
installed). `--features hardware` is **required** or the peripheral tools are stripped. Then push
the binary to `…/apess-onboard/bin/zeroclaw` and `app restart`, or repackage the distributable app
with `package-onboard-app.sh`.
+87
View File
@@ -0,0 +1,87 @@
# APESS onboarding — fully containerized
> **The setup runbook.** For the participant *journey* (each screen's job, the
> module→ADD-layer map, open design questions), see
> [`WORKSHOP-FLOW.md`](./WORKSHOP-FLOW.md). For a board with the **linear
> actuator** (stepper bring-up, calibration, and the park-and-`zero`-before-restart
> rule), see [`ACTUATOR.md`](./ACTUATOR.md).
A team needs two things running: the **APESS stack on their laptop** and the
**ZeroClaw node on their Uno Q**. Both are containers. Nothing installs to a host.
```
LAPTOP: docker compose up → apess-api + apess-web (deploy/lan)
BOARD : App Lab → Run → ONE container = daemon + relay + responder
```
Everything a team does — say-hi, the module chat, Telegram, the LED matrix (text,
patterns, and the 0..N counter), the I2C scan — runs through this. The board never
needs the Zephyr flash toolchain or Linux `/dev/i2c`: the matrix is driven by a
resident responder and I2C is scanned on the MCU (Wire), both over the RouterBridge
relay.
## 1. Instructor — build + host the app (once)
Bake the cloud token + all assets into an App Lab **import archive** (a zip):
```sh
export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-… # baked into the bundle
./deploy/uno-q/package-onboard-app.sh # → dist/apess-onboard/ + dist/apess-onboard.zip
```
The bundle contains: the ZeroClaw binary (`matrix_text`, `matrix_pattern`,
`matrix_count`, `i2c_scan`), the single-`default`-agent config (those tools
allowlisted), the skills, the responder sketch, and the baked token. Telegram
ships **disabled** with an empty token (a tokenless channel would spam startup
probes) — the Phase-1 wizard flips it on when a team opts in. It ships **without**
a `.secret_key` (each board mints its own on first Run) and **without** any team's
Telegram token. The `dist/` output is gitignored (it holds the token).
The zip is a standard App Lab export archive (top dir = app name) — verified to
round-trip through `arduino-app-cli app import`. **Host it for download:**
```sh
# copy dist/apess-onboard.zip to the production static path, e.g.
# apess.redclaw.dev/download/apess-onboard.zip
```
## 2. Student — bring up their board (fresh OR pre-existing)
1. Open **App Lab** on the Uno Q (it ships with the board).
2. Download the app zip from `apess.redclaw.dev/download/apess-onboard.zip`.
3. In App Lab → **Import an app** → pick the zip. It's added to your workspace
with its files, bricks, and libraries.
4. Click **Run**. The node comes up in one container: on first Run it
auto-installs the sketch's core + libraries (RouterBridge, ArduinoGraphics),
flashes the resident responder, launches the cloud agent, mints its
`.secret_key`, self-registers to the team's APESS laptop, and scrolls a
**claim code** on the LED matrix.
Fresh or pre-existing board is identical — Run is idempotent; it just (re)starts
the node. (First Run also pulls the ~839 MB `python-apps-base` image — pre-seed
that on the room's network if 15 teams start at once.)
## 3. Student — the wizard (laptop)
- Open `http://<laptop>/` → **Start the workshop**.
- **Phase 1:** team name + members → type the **claim code** the matrix is
scrolling → board bound (state persists in `localStorage`; a Disconnect button
is the only thing that drops it).
- Say hi to the agent, optionally set up **Telegram** (writes the token to the
node and reloads it), toggle **Voice**.
- **Phase 2:** open the node, name the domain. Then the modules.
## `APESS_URL` — how the board finds the laptop
The board self-registers to `APESS_URL` (default `http://apess-api.local:3000`).
Options, easiest first:
- **mDNS:** have the `deploy/lan` box advertise `apess-api.local` (zero-config
for students).
- **Per-team:** set `APESS_URL` in the app's `.zeroclaw/apess-node.env`, or pass
`APESS_URL=http://<laptop-ip>:3000` when packaging.
## Reachability
The laptop's API must reach the board over the workshop WiFi. Verify the AP
allows **client-to-client** traffic (many guest networks isolate clients). See
`deploy/lan/README.md`.
+134
View File
@@ -0,0 +1,134 @@
# APESS 2026 Workshop — Laptop Prerequisites
**"Design the Agent Your Building Deserves" · 27 July · a 5-hour build session.**
Do these **before you arrive** so we spend the session building, not installing.
> Companion docs: [`WORKSHOP-FLOW.md`](./WORKSHOP-FLOW.md) (what you'll do) ·
> [`ONBOARDING.md`](./ONBOARDING.md) (how the board comes up).
---
## How it runs (so the prerequisites make sense)
Each team runs the **whole platform on its own laptop** — a small Docker stack (web + API) that
comes up with **one command**. Your **Arduino Uno Q** plugs into that same laptop over **USB**.
Everything on your **laptop** is **localhost**: the browser, the API, and the board all talk on your
machine. Once the stack is up and the board is plugged in, it **auto-connects to your team — no
codes, no accounts.** The one thing that leaves the box: the **board** reaches its **AI cloud brain
over the venue WiFi** — but *we* pre-join each board to that network before you get it, so there's
nothing for you to set up.
> **Most teams are on Windows 11** (a few Macs). Both work the same way; the only difference is the
> command you run to attach the board — see the Windows / macOS notes below.
So each team needs **one "board laptop"** with a few things pre-installed. Extra teammates just
need a browser pointed at that laptop.
---
## TL;DR
- **Board laptop:** install **Docker**, **adb**, and **git**; pull the workshop bundle ahead of time.
- **Everyone else:** a modern browser is enough.
- **No accounts, no API keys** — the AI cloud access is baked into the board app.
---
## What the workshop provides (do NOT install)
- **Arduino Uno Q (4 GB)** board + **USB-C cable** — one per team.
- **ADXL355 accelerometer(s)** + wiring — the FabLab kit.
- **Cloud AI access** — baked into the board app. **No Anthropic/Claude account needed.**
- **Boards pre-joined to the venue WiFi** — the board uses it only to reach the AI cloud; you don't
configure any network.
- The **web app** itself (you run it locally from the bundle below).
---
## The team "board laptop" — pre-install these (large downloads, do them at home)
1. **Docker** — Docker Desktop (macOS/Windows) or Docker Engine + Compose (Linux).
Verify: `docker run hello-world` succeeds.
2. **adb** (Android platform-tools) — the USB bridge to the board.
Verify: `adb version` prints a version. (macOS: `brew install android-platform-tools`.)
3. **git** — to fetch the workshop bundle. Verify: `git --version`.
4. **~10 GB free disk** — Docker images (web + API) + the board's App Lab base image (~0.9 GB).
5. **The workshop bundle, pre-fetched** so you're not downloading on the WiFi at 14:00:
```sh
git clone <workshop-repo-url> # [instructor: final repo/bundle URL]
cd <repo>/deploy/lan
docker compose up -d --build # pre-build the images once, at home
docker compose down # then stop until the day
```
(Also pre-pull the App Lab base image on the board — it's fetched on first Run.)
### On Windows 11 (most teams)
- **Docker Desktop** with the **WSL2 backend** (enable it in the installer). `docker run hello-world`.
- **adb** — download **Android SDK platform-tools for Windows**, unzip it, and add the folder to your
**PATH** (so `adb version` works in a new terminal). *Run adb on Windows itself — not inside WSL.*
- **Git for Windows** — gives you `git` + `curl` (used by the stack).
- **Uno Q USB driver** — plug the board in; Windows usually installs a driver automatically. It's
working when **`adb devices`** lists the board as `device` (see the check below). If it shows
nothing or `unauthorized`, reinstall the driver / re-plug and accept any prompt on the board.
- To attach the board you'll run **`connect-board.bat`** (double-click) or **`connect-board.ps1`** —
both live in `deploy\lan\`.
### On macOS (a few teams)
- Docker Desktop, `brew install android-platform-tools` (adb), `git`. Attach with
`./deploy/lan/connect-board.sh`.
### First check: does adb see your board?
The #1 thing to get right up front. Plug the Uno Q in over USB and run:
```
adb devices
```
You want a line ending in **`device`**, e.g. `65301572 device`. If it's empty, `offline`, or
`unauthorized`: re-plug, try a different USB port/cable, and on Windows reinstall the USB driver.
**Get this working before the day** — everything else assumes adb sees the board.
## Everyone else on the team
- A **current browser** (Chrome/Edge recommended; Firefox works). That's it — you'll open the board
laptop's local URL.
---
## What you'll do on the day (no accounts, no claim codes)
1. **Bring the stack up:** `cd deploy/lan && docker compose up -d` → open **`http://localhost:8090/`**.
2. **Get the board app:** in Team Registration, click **Download the board app** (served by your own
stack), then on the Uno Q: **App Lab → Import an app → pick the zip → Run.** `[instructor: confirm the App Lab access flow for the room]`
3. **Attach the board:** plug the Uno Q into the board laptop over USB, then:
- **Windows:** double-click **`deploy\lan\connect-board.bat`** (or run `.\connect-board.ps1 -Watch`
to keep it auto-attaching on re-plug).
- **macOS:** run **`./deploy/lan/connect-board.sh`** (or `--watch`).
4. **It just connects:** type your team name and the board **auto-binds to your team** — no code.
Unplug/replug is handled automatically; a **Disconnect / Reconnect** control is there if you need it.
5. **Build:** walk Modules 1–3, submit your Agent Design Document.
---
## Pre-flight self-check (before you travel)
- [ ] **(Board laptop)** `docker run hello-world` works.
- [ ] **(Board laptop)** `adb version` and `git --version` work.
- [ ] **(Board laptop)** Ran `docker compose up -d --build` once (images built) and opened `localhost:8090`.
- [ ] Laptop charged + charger packed (5-hour session).
- [ ] A current browser.
---
## No accounts to create
- ❌ No Anthropic / Claude account or API key — the cloud key is baked into the board app.
- ❌ No claim codes — in this local USB setup the board auto-connects.
- ✅ Everything is localhost; nothing depends on the room WiFi.
---
## Notes for the curious (why these specific tools)
- **Docker** runs the web + API stack in one command, identically on every laptop.
- **adb** carries the board over USB; the API (in a container) reaches it via `host.docker.internal`.
- The web runs on **`:8090`** (not `:8080`) because `:8080` is the board's own port, forwarded over adb.
---
## Instructor checklist (finalize before publishing to students)
- [ ] **The workshop bundle URL** (git repo or a downloadable archive) students clone/pull.
- [ ] **Pre-built image distribution** — consider publishing `apess-web`/`apess-api` to a registry (or a
USB `docker load` bundle) so teams `docker compose up` without a source build on the day.
- [ ] **Exact Arduino App Lab access** on the Uno Q for the room (and whether it needs any login).
- [ ] **Pre-seed** the ~0.9 GB App Lab base image locally so 9 teams don't each pull it live.
- [ ] Decide whether `connect-board.sh --watch` runs via a small launchd/systemd unit (hands-free re-plug).
+80
View File
@@ -0,0 +1,80 @@
# Exposing participant files to the agent (`/app/workspace`)
Goal: let the containerized agent **read, correct, and complete a student's own
Arduino code**. By default it can't — the App Lab container only mounts its own app
directory. This wires the student's files in.
## Where participants store their files (Uno Q, via App Lab)
| Location on the board | What it holds | Created when… |
|---|---|---|
| `~/sketches/<name>/<name>.ino` | **Sketches** from App Lab's sketch editor — the primary place implementations live | student opens the sketch editor and saves |
| `~/ArduinoApps/<name>/` | Full **App Lab apps** (`app.yaml` + `python/` + `sketch/` + `web/`) | student runs *New App* |
| `~/Arduino/libraries/` | Installed Arduino **libraries** | library manager / `arduino-cli lib install` |
(Our own node is `~/ArduinoApps/apess-onboard` — excluded from the mount to avoid a
recursive self-mount.)
## Why a bind-mount, and why at boot
- App Lab's `app.yaml` has **no `volumes` field**; it generates the compose itself
(`.cache/app-compose.yaml`) with a fixed hardware mount profile — no injection point.
- The container's `/app` bind is **`rprivate`**, so a host submount added *after* the
container starts does **not** propagate in. The binds must exist **before** the app
container is created.
- `mount(2)` is privileged → this runs as **root at boot, ordered before
`arduino-app-cli.service`** (the App Lab daemon that launches the default app).
Result inside the container:
```
/app/workspace/sketches/ <- ~/sketches (rw)
/app/workspace/apps/<name>/ <- ~/ArduinoApps/<name> (rw, minus apess-onboard)
/app/workspace/libraries/ <- ~/Arduino/libraries (ro)
```
The agent is told about this in the `uno-q-hardware` skill ("The student's own files").
## Install (on the board, once — needs root)
```sh
# copy the mount script + unit onto the board
adb push deploy/uno-q/mount-user-workspace.sh /home/arduino/mount-user-workspace.sh
adb shell 'chmod +x /home/arduino/mount-user-workspace.sh'
adb push deploy/uno-q/systemd/apess-user-workspace.service /tmp/apess-user-workspace.service
adb shell 'sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo systemctl daemon-reload \
&& sudo systemctl enable --now apess-user-workspace.service'
# the binds only reach the ALREADY-running container after it is recreated
# (rprivate), so restart the app once:
adb shell 'arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard'
# verify
adb shell 'docker exec apess-onboard-main-1 ls -la /app/workspace/sketches'
```
After this it survives reboots (the unit runs before the app each boot).
## Live vs. restart
- **New sketches** (`~/sketches/...`) appear **live** — they're files inside the
single `~/sketches` bind, not new mounts. No restart needed.
- A **new sibling App Lab app** is a new mount → re-run the script and restart the
app: `sudo /home/arduino/mount-user-workspace.sh && arduino-app-cli app restart …`.
## Wired into provisioning
- **`provision-fleet.sh`** (`MODE=systemd`) — pushes the script + unit, `sudo -n`
installs/enables it, and restarts the app, per board. Falls back to a staged-file
message if root isn't available (mount can't run cron-only).
- **`provision-node-app.sh`** — same, for a single dev board (targets whichever app
it provisions via `.apess-workspace.env` → `APP_DIR`).
- **`package-onboard-app.sh`** — bundles `mount-user-workspace.sh` +
`apess-user-workspace.service` into the app under `host-setup/` (plus this doc as
`host-setup/README.md`), since App Lab self-import can't run root steps. The
epilogue prints the one-time enable command for imported boards.
The unit reads `APP_DIR` from `/home/arduino/.apess-workspace.env` (default
`…/apess-onboard`), so the same unit works for both the distributable and the dev app.
+137
View File
@@ -0,0 +1,137 @@
# APESS 2026 — Workshop flow
**From a cold laptop to a shipped agent design.** The end-to-end path a team walks
today — every screen, the job it does, what the person *does* versus what they
*see*, and the spots we think the flow can get better.
> Shared for design review. Companion to [`ONBOARDING.md`](./ONBOARDING.md) (the
> operational runbook) — this doc is the *journey*, that one is the *setup*.
`Arduino Uno Q · 4GB` · `ZeroClaw edge agent` · `FabLab Torino` · `27 Jul 2026`
---
## Before the room fills — launch the local stack
The workshop runs **on the team's own laptop**, right next to the board — a
cloud API can't reach devices behind the room's NAT. One command brings up the
web app and the API together.
```sh
# bring up web + API on the team laptop (same WiFi as the board)
cd deploy/lan
cp .env.example .env # set ADMIN_CODE, JUDGE_CODE, FLEET_SECRET
docker compose --env-file .env -f docker-compose.yml up -d --build
# → web on :80 · API proxied same-origin at /api · board app served at /download/
# ✓ everyone (team · board · judge) opens http://<laptop-ip>/
```
- **Local-first** — autonomous and offline-capable; nothing depends on the cloud during the session.
- **Shared secret** — `FLEET_SECRET` must match the value baked into the board app, or self-registration is rejected.
- **The board app** — the same laptop serves `/download/apess-onboard.zip`, the one-click App Lab app the team imports next.
---
## The through-line — one document, built five layers deep
Every screen after setup adds a layer to the team's **Agent Design Document
(ADD)**. The live board proves each capability as they design it — the modules
aren't lessons, they're the ADD taking shape.
| Layer | What it captures | Where |
|-------|------------------|-------|
| **Layer 1** | Domain & events | Module 1 |
| **Layer 2** | Skills | Module 2 |
| **Layer 3** | Policies & failure | Module 2 |
| **Layer 4** | Harness | Module 3 |
| **Layer 5** | Loops | Module 3 |
---
## The path, screen by screen
### ◦ Landing — `/`
Sets the frame: a Claude agent on the edge, on real hardware. One door in.
- **Does** — clicks **Start the workshop**.
- **Sees** — the pitch, the presenter, the single call to action — nothing else competing.
### ① Team registration — `/workshop` · Phase 1
Claim a name, and claim a board — the moment the physical device becomes *this
team's* agent.
- **Does** — enters team name + members → downloads the board app → App Lab **Import → Run** → types the code the matrix scrolls → **Bind board**.
- **Sees** — the board light up and scroll its claim code; on bind, three cards: *Say hi*, *Set up Telegram*, *Enable voice*.
- **System** — node self-registers to the laptop; the bearer token moves pool→bridge and never touches the browser.
- ⚠ **Watch** — first-connection is the busiest moment in the flow: binding plus three optional channel cards all land at once. Worth sequencing.
### ② Meet your agent — `/workshop/setup` · Phase 2
Introduce the agent, then name the **domain** it will serve — the seed the whole
ADD grows from.
- **Does** — opens the agent dashboard to explore, then **picks a domain** (e.g. "structural stress").
- **Sees** — the live ZeroClaw dashboard on the board; a single domain input that gates progress.
- ⚠ **Watch** — the domain drives every later layer but is introduced almost in passing. Does it deserve more weight this early?
### ③ Module 1 — Domain & events — `/workshop/module1` · ADD Layer 1
Turn the chosen domain into the world the agent lives in and the events it
reacts to.
- **Sees** — their domain carried over, read-only.
- **Does** — drafts Layer 1 of the Agent Design Document.
### ④ Module 2 — Skills & policies — `/workshop/module2` · ADD Layers 2–3
The hands-on core: talk to the agent, watch it use real tools on the board, then
codify what it can do and what governs it.
| Prompt | Tool | What it proves |
|--------|------|----------------|
| List the I2C devices on the bus | `i2c_scan` | reads real hardware |
| Count to 100, once a second, on the matrix | `matrix_count` | a timed loop on the MCU |
| Scroll GO CLAWS on the matrix | `matrix_text` | instant runtime display |
- **Sees** — each tool's *actual result* stream back into the chat — no flashing, all in-container.
- **Does** — runs all three → **What's next** unlocks Layers 2 & 3 (Skills, Policies & failure).
- ⚠ **Watch** — Module 2 alone carries two ADD layers plus the only live-hardware moment — heavier than 1 and 3. Prompts must be phrased as commands, since that's what reliably drives tools.
### ⑤ Module 3 — Harness, loops & submit — `/workshop/add` · ADD Layers 4–5
Finish the design: where each decision runs, how it repeats, and what happens
when a cycle fails — then ship it.
- **Does** — drafts Layer 4 (Harness) & Layer 5 (Loops), reviews the assembled document, hits **Submit ADD**.
- **Sees** — all five layers in one place; a confirmed submission.
- **System** — the ADD lands in the API, ready for judging at `/judge`.
---
## Behind the scenes
- **Fleet & scoring** — `/admin` shows every team, their phase and board health; `/judge` scores the submitted ADDs across the cohort.
- **Persistence** — team, board binding and progress live in the browser; a refresh or nav-away resumes where they left off, until an explicit Disconnect.
- **Reachability** — board and laptop share WiFi; the board self-registers over mDNS. If the room WiFi isolates clients, USB tethering is the fallback path.
- **One agent, no flashing** — a single agent with all skills. Matrix and I2C run on a resident MCU responder over a socket — instant, in-container, nothing to re-flash mid-workshop.
---
## For the designer — where the flow could get better
The honest open questions — where the current path works but feels uneven. This
is what we'd love a fresh eye on.
1. **The first-connection pile-up** — binding the board and three optional channel setups (say-hi, Telegram, voice) all appear at the same instant. What's the right sequence — celebrate the connection first, then offer channels?
2. **Uneven module weight** — layers map 1 → 2·3 → 4·5 across the three modules, so Module 2 does double duty *and* owns the only live-hardware moment. Rebalance the pacing, or split Module 2?
3. **The domain's quiet debut** — the domain seeds all five layers yet is chosen in one small field during "Meet your agent." Does it need a stronger framing moment?
4. **Feedback for a slow agent** — a cloud round-trip can take seconds; a tool result streams back as plain lines. What does "the agent is thinking / working" look like so waiting never reads as broken?
5. **When the board drops** — USB unplugs and WiFi isolation are real. The recovery path exists but is invisible to the team — how should a disconnect surface, and guide them back?
---
*Current-state workflow — APESS 2026 · RedClaw · Uno Q + ZeroClaw.*
*A rendered version of this doc is available as a shareable web page (ask the presenter for the link).*
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
launchd agent: auto-run the board recovery watcher.
It keeps `recover.sh --watch` alive, which sits on `adb wait-for-device` and
re-forwards tunnels + re-registers the node with the local API every time the
Uno Q reconnects — so a USB re-plug heals itself with no manual step.
Install (per-user):
cp deploy/uno-q/com.redclaw.apess-board-recover.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.redclaw.apess-board-recover.plist
Stop / uninstall:
launchctl unload ~/Library/LaunchAgents/com.redclaw.apess-board-recover.plist
Logs: ~/Library/Logs/apess-board-recover.log
Edit the paths below if your checkout lives elsewhere.
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.redclaw.apess-board-recover</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/quantum/projects/apress/deploy/uno-q/recover.sh</string>
<string>--watch</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<!-- launchd's PATH is minimal; add Homebrew + platform-tools for adb/python3/curl. -->
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<!-- Override any of recover.sh's knobs here if needed, e.g.: -->
<!-- <key>CLAIM_CODE</key><string>7777</string> -->
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/Users/quantum/Library/Logs/apess-board-recover.log</string>
<key>StandardErrorPath</key>
<string>/Users/quantum/Library/Logs/apess-board-recover.log</string>
</dict>
</plist>
+15 -6
View File
@@ -82,8 +82,8 @@ prompt_injection_mode = "compact"
# without a human approver (the webhook path is non-interactive).
[risk_profiles.default]
level = "supervised"
allowed_tools = ["uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
auto_approve = ["uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
allowed_tools = ["matrix_pattern", "matrix_text", "matrix_count", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
auto_approve = ["matrix_pattern", "matrix_text", "matrix_count", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
[runtime_profiles.unoq]
agentic = true
@@ -94,7 +94,13 @@ strict_tool_parsing = false
# Agents — one per provider strategy. APESS routes here via ?agent=; callers
# pass the alias explicitly (see sendPrompt in src/lib/api.ts).
# ---------------------------------------------------------------------------
[agents.default] # cloud + on-board Qwen fallback — the workshop default
# THE ONE workshop agent. Everything routes here — web say-hi, the module chat,
# Refine, and Telegram — via the frontend's single `AGENT` constant (= "default")
# and the daemon's no-alias fallback. It's fully loaded: cloud model + on-board
# Qwen fallback, risk_profile "default" (all tools), skill_bundles "unoq" (all
# skills). The other agents below are DISABLED so no interaction can land on the
# wrong one; a stray request for a disabled alias falls back to this agent.
[agents.default] # cloud + on-board Qwen fallback — the sole workshop agent
enabled = true
model_provider = "custom.claude"
risk_profile = "default"
@@ -104,14 +110,17 @@ runtime_profile = "unoq"
# telegram) by pasting their @BotFather token; the reload-watcher applies it.
channels = ["telegram.default"]
# Demo-mode variants — DISABLED for the workshop so there's exactly one agent.
# Re-enable individually only if you specifically want to demo cloud-only /
# offline-only / simulated-outage behaviour.
[agents.cloud] # cloud only, no fallback
enabled = true
enabled = false
model_provider = "custom.cloud"
risk_profile = "default"
runtime_profile = "unoq"
[agents.local] # on-board Qwen only (fully offline)
enabled = true
enabled = false
model_provider = "llamacpp.local"
risk_profile = "default"
runtime_profile = "unoq"
@@ -139,7 +148,7 @@ mention_only = false
# [channels.voice_duplex.default]
# enabled = true
[agents.chaos] # simulated cloud outage → falls back to on-board Qwen
enabled = true
enabled = false # DISABLED — see the single-agent note on [agents.default]
model_provider = "custom.dead"
risk_profile = "default"
runtime_profile = "unoq"
+74
View File
@@ -0,0 +1,74 @@
# matrix-relay — a tiny TCP:9999 → RouterBridge relay for the resident MCU sketch.
#
# The ZeroClaw daemon runs on the HOST (for full hardware access), but its
# matrix_pattern / matrix_text tools speak a simple line protocol to :9999, and
# the actual MCU is reached via the Arduino RouterBridge (msgpack-rpc over
# /run/arduino-router.sock) whose python binding (arduino.app_utils) only ships
# in the App Lab container image. So we run JUST this relay in a minimal
# container that mounts the router socket and publishes :9999 to the host.
#
# Protocol (one line per connection):
# ping -> pong
# matrix <0-7> -> Bridge.call("matrix_set", id) preset animation
# text <words...> -> Bridge.call("matrix_text", str) scroll text
# i2c -> Bridge.call("i2c_scan") list I2C devices (MCU)
# gpio_write <p> <v>-> Bridge.call("digitalWrite", p, v)
# gpio_read <p> -> Bridge.call("digitalRead", p) -> value
import socket
import threading
from arduino.app_utils import Bridge # RouterBridge client (container-only binding)
PORT = 9999
def handle(conn):
try:
data = conn.recv(256).decode().strip()
parts = data.split()
cmd = parts[0].lower() if parts else ""
if cmd == "ping":
conn.sendall(b"pong\n")
elif cmd == "matrix" and len(parts) >= 2:
Bridge.call("matrix_set", int(parts[1]))
conn.sendall(b"ok\n")
elif cmd == "text" and len(parts) >= 2:
Bridge.call("matrix_text", " ".join(parts[1:]))
conn.sendall(b"ok\n")
elif cmd == "count" and len(parts) >= 2:
Bridge.call("matrix_count", int(parts[1]))
conn.sendall(b"ok\n")
elif cmd == "matrixget":
r = Bridge.call("matrix_get")
conn.sendall(f"{r}\n".encode())
elif cmd == "i2c":
r = Bridge.call("i2c_scan")
conn.sendall(f"{r}\n".encode())
elif cmd == "gpio_write" and len(parts) >= 3:
Bridge.call("digitalWrite", int(parts[1]), int(parts[2]))
conn.sendall(b"ok\n")
elif cmd == "gpio_read" and len(parts) >= 2:
conn.sendall(f"{Bridge.call('digitalRead', int(parts[1]))}\n".encode())
else:
conn.sendall(b"error: invalid command\n")
except Exception as e:
try:
conn.sendall(f"error: {e}\n".encode())
except Exception:
pass
finally:
conn.close()
def main():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", PORT))
s.listen(5)
print(f"matrix-relay listening on :{PORT}", flush=True)
while True:
conn, _ = s.accept()
threading.Thread(target=handle, args=(conn,), daemon=True).start()
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# run-relay.sh — (re)start the matrix-relay container on the Uno Q host.
# Runs the TCP:9999 → RouterBridge relay in a minimal container (the RouterBridge
# python binding only ships in the App Lab image). Mounts the router socket,
# publishes :9999 to loopback for the host ZeroClaw daemon's matrix tools.
set -eu
IMAGE="ghcr.io/arduino/app-bricks/python-apps-base:0.11.0"
RELAY="${RELAY:-/home/arduino/matrix-relay/relay.py}"
docker rm -f matrix-relay >/dev/null 2>&1 || true
docker run -d --name matrix-relay --restart unless-stopped \
-v /run/arduino-router.sock:/var/run/arduino-router.sock \
-v "$RELAY":/relay.py:ro \
-p 127.0.0.1:9999:9999 \
--entrypoint python3 \
"$IMAGE" /relay.py
echo "matrix-relay started (:9999 → RouterBridge)"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Expose each participant's own Arduino files INTO the apess-onboard app container
# so the agent can read, correct, and complete their implementation.
#
# WHY THIS EXISTS
# The App Lab container only bind-mounts the app's own directory
# (/home/arduino/ArduinoApps/apess-onboard -> /app). A student's real work lives
# elsewhere and is invisible to the agent:
# ~/sketches/<name>/<name>.ino App Lab "sketch editor" projects (primary)
# ~/ArduinoApps/<name>/ full App Lab apps
# ~/Arduino/libraries/ installed libraries
# App Lab has NO volumes field in app.yaml and generates the compose itself, so
# we can't declare these there. Instead we bind the user paths UNDERNEATH the app
# dir (which /app already maps). The catch: the /app bind is `rprivate`, so a
# submount added AFTER the container starts does NOT propagate in — the binds must
# exist BEFORE the app container is created. Hence a root oneshot ordered
# Before=arduino-app-cli.service (see apess-user-workspace.service).
#
# RESULT INSIDE THE CONTAINER
# /app/workspace/sketches/ <- ~/sketches (rw)
# /app/workspace/apps/<name>/ <- ~/ArduinoApps/<name> (rw, minus ourselves)
# /app/workspace/libraries/ <- ~/Arduino/libraries (ro, reference)
#
# Idempotent — safe to re-run. Requires root (mount(2) is privileged).
set -euo pipefail
USER_HOME=${USER_HOME:-/home/arduino}
APP_DIR=${APP_DIR:-$USER_HOME/ArduinoApps/apess-onboard}
WS="$APP_DIR/workspace"
SELF=$(basename "$APP_DIR")
if [ "$(id -u)" -ne 0 ]; then
echo "must run as root (mount is privileged) — try: sudo $0" >&2
exit 1
fi
bind() { # src dst [ro]
local src=$1 dst=$2 ro=${3:-}
if [ ! -d "$src" ]; then echo "skip (no source dir): $src"; return 0; fi
mkdir -p "$dst"
if mountpoint -q "$dst"; then echo "already mounted: $dst"; return 0; fi
mount --bind "$src" "$dst"
[ "$ro" = ro ] && mount -o remount,ro,bind "$dst"
echo "mounted: $src -> $dst${ro:+ (ro)}"
}
mkdir -p "$WS" "$WS/apps"
chown "$(stat -c '%u:%g' "$USER_HOME")" "$WS" "$WS/apps" 2>/dev/null || true
bind "$USER_HOME/sketches" "$WS/sketches"
bind "$USER_HOME/Arduino/libraries" "$WS/libraries" ro
# Each OTHER App Lab app — skip ourselves so we don't recursively self-mount.
if [ -d "$USER_HOME/ArduinoApps" ]; then
for d in "$USER_HOME/ArduinoApps"/*/; do
[ -d "$d" ] || continue
name=$(basename "$d")
[ "$name" = "$SELF" ] && continue
bind "$d" "$WS/apps/$name"
done
fi
echo "workspace ready: $WS"
echo "note: a NEW sibling app created after boot needs a re-run + app restart to appear"
echo " (rprivate /app); new *sketches* in ~/sketches appear live, no restart needed."
+409
View File
@@ -0,0 +1,409 @@
schema_version = 3
[providers.models.openrouter.default]
temperature = 0.3
[risk_profiles.default.delegation_policy]
mode = "forbidden"
[risk_profiles.sense_only.delegation_policy]
mode = "forbidden"
[risk_profiles.field_ops.delegation_policy]
mode = "forbidden"
[risk_profiles.field_flash.delegation_policy]
mode = "forbidden"
[risk_profiles.demo.delegation_policy]
mode = "forbidden"
[runtime_profiles.unoq.context_compression]
enabled = true
identifier_policy = "strict"
max_passes = 3
protect_first_n = 3
protect_last_n = 4
source_max_chars = 50000
summary_max_chars = 4000
summary_provider = ""
threshold_ratio = 0.5
timeout_secs = 60
tool_result_retrim_chars = 2000
tool_result_trim_exempt = []
[runtime_profiles.unoq.eval]
enabled = false
max_retries = 1
min_quality_score = 0.5
[runtime_profiles.unoq.history_pruning]
collapse_tool_results = true
enabled = false
keep_recent = 4
max_tokens = 8192
[runtime_profiles.unoq.thinking]
default_level = "medium"
native_thinking = false
[runtime_profiles.unoq.tool_receipts]
enabled = false
inject_system_prompt = true
show_in_response = false
[runtime_profiles.offline.context_compression]
enabled = true
identifier_policy = "strict"
max_passes = 3
protect_first_n = 3
protect_last_n = 4
source_max_chars = 50000
summary_max_chars = 4000
summary_provider = ""
threshold_ratio = 0.5
timeout_secs = 60
tool_result_retrim_chars = 2000
tool_result_trim_exempt = []
[runtime_profiles.offline.eval]
enabled = false
max_retries = 1
min_quality_score = 0.5
[runtime_profiles.offline.history_pruning]
collapse_tool_results = true
enabled = false
keep_recent = 4
max_tokens = 8192
[runtime_profiles.offline.thinking]
default_level = "medium"
native_thinking = false
[runtime_profiles.offline.tool_receipts]
enabled = false
inject_system_prompt = true
show_in_response = false
[agents.default.a2a]
exposed_skills = []
published = false
[agents.default.identity]
format = "openclaw"
[agents.default.memory]
backend = "sqlite"
[agents.default.precheck]
enabled = true
timeout_secs = 5
[agents.default.workspace]
read_memory_from = []
unrestricted_filesystem = false
[agents.cloud.a2a]
exposed_skills = []
published = false
[agents.cloud.identity]
format = "openclaw"
[agents.cloud.memory]
backend = "sqlite"
[agents.cloud.precheck]
enabled = true
timeout_secs = 5
[agents.cloud.workspace]
read_memory_from = []
unrestricted_filesystem = false
[agents.demo.a2a]
exposed_skills = []
published = false
[agents.demo.identity]
format = "openclaw"
[agents.demo.memory]
backend = "sqlite"
[agents.demo.precheck]
enabled = true
timeout_secs = 5
[agents.demo.workspace]
read_memory_from = []
unrestricted_filesystem = false
[[peripherals.boards]]
baud = 115200
board = "arduino-uno-q"
transport = "bridge"
[peer_groups.telegram_default]
admin_for_agent_scope = false
agents = []
channel = "telegram.default"
external_peers = ["8512813413"]
ignore = []
output_modality = "mirror"
[providers.models.custom.cloud]
uri = "http://127.0.0.1:8091/v1"
model = "sonnet"
native_tools = false
[runtime_profiles.unoq.thinking.budget_tokens]
[runtime_profiles.offline.thinking.budget_tokens]
[agents.default.workspace.access]
[agents.cloud.workspace.access]
[agents.demo.workspace.access]
# Native Anthropic via a Claude Max setup-token (sk-ant-oat01-…). Credential
# comes from the ANTHROPIC_OAUTH_TOKEN env on the daemon — kept OFF DISK.
[providers.models.anthropic.max]
model = "claude-sonnet-5"
[providers.models.custom.claude]
uri = "http://127.0.0.1:8091/v1"
model = "sonnet"
native_tools = false
fallback = ["llamacpp.local"]
[providers.models.llamacpp]
[providers.models.llamacpp.local]
uri = "http://127.0.0.1:8083/v1"
timeout_secs = 300
model = "qwen"
native_tools = false
[providers.models.custom]
[gateway]
port = 8080
host = "0.0.0.0"
allow_public_bind = true
require_pairing = false
web_dist_dir = "/app/web-dist"
paired_tokens = []
[skills]
prompt_injection_mode = "compact"
[risk_profiles.default]
level = "supervised"
allowed_tools = ["matrix_pattern", "matrix_text", "matrix_count", "i2c_scan", "stepper", "stepper_status", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
auto_approve = ["matrix_pattern", "matrix_text", "matrix_count", "i2c_scan", "stepper", "stepper_status", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search", "memory_recall", "web_search_tool", "web_fetch", "calculator", "glob_search", "image_info", "weather", "tool_search", "browser", "browser_open"]
allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep", "find", "echo", "pwd", "wc", "head", "tail", "date", "df", "du", "uname", "uptime", "hostname", "python", "python3", "pip", "node", "free"]
allowed_roots = []
always_ask = []
block_high_risk_commands = true
excluded_tools = []
firejail_args = []
forbidden_paths = ["/etc", "/root", "/home", "/usr", "/bin", "/sbin", "/lib", "/opt", "/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "~/.ssh", "~/.gnupg", "~/.aws", "~/.config"]
require_approval_for_medium_risk = true
shell_env_passthrough = []
workspace_only = true
[runtime_profiles.unoq]
agentic = true
max_tool_iterations = 6
strict_tool_parsing = false
max_actions_per_hour = 20
max_cost_per_day_cents = 500
max_delegation_depth = 0
shell_timeout_secs = 60
tool_call_dedup_exempt = []
tool_filter_groups = []
[agents.default]
enabled = true
model_provider = "anthropic.max"
risk_profile = "default"
runtime_profile = "unoq"
acp_enable_mcp = false
channels = ["telegram.default"]
classifier_provider = ""
cron_jobs = []
delegate_same_risk_profile = true
delegates = []
knowledge_bundles = []
mcp_bundles = []
skill_bundles = ["unoq"]
summary_provider = ""
transcription_provider = ""
tts_provider = ""
[peripherals]
enabled = true
[agents.cloud]
enabled = false
model_provider = "anthropic.max"
risk_profile = "default"
runtime_profile = "unoq"
acp_enable_mcp = false
channels = []
classifier_provider = ""
cron_jobs = []
delegate_same_risk_profile = true
delegates = []
knowledge_bundles = []
mcp_bundles = []
skill_bundles = ["unoq"]
summary_provider = ""
transcription_provider = ""
tts_provider = ""
[providers.models.custom.dead]
uri = "http://127.0.0.1:9099/v1"
model = "sonnet"
native_tools = false
fallback = ["llamacpp.local"]
[channels.voice_duplex.default]
enabled = true
excluded_tools = []
# ---------------------------------------------------------------------------
# LEAN OFFLINE PROFILE (experiment 2026-07-19)
# Everything that controls prompt size lives on the runtime profile.
# Goal: get the prompt from ~4718 tokens down under ~800 so the on-board
# 0.5B (17 tok/s prefill) can actually answer in seconds, not minutes.
# ---------------------------------------------------------------------------
[runtime_profiles.offline]
agentic = true
max_tool_iterations = 3
strict_tool_parsing = false
compact_context = true
prompt_injection_mode = "compact"
max_system_prompt_chars = 2000
max_context_tokens = 3000
max_history_messages = 2
memory_recall_limit = 1
parallel_tools = false
max_actions_per_hour = 20
max_cost_per_day_cents = 500
max_delegation_depth = 0
shell_timeout_secs = 60
tool_call_dedup_exempt = []
tool_filter_groups = []
# Narrow tool surface. Dropping the other peripheral tools also drops the
# hardware block + Uno-Q flash imperative from the system prompt entirely.
[risk_profiles.sense_only]
level = "supervised"
allowed_tools = ["i2cdetect"]
auto_approve = ["i2cdetect"]
allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep", "find", "echo", "pwd", "wc", "head", "tail", "date", "df", "du", "uname", "uptime", "hostname", "python", "python3", "pip", "node", "free"]
allowed_roots = []
always_ask = []
block_high_risk_commands = true
excluded_tools = []
firejail_args = []
forbidden_paths = ["/etc", "/root", "/home", "/usr", "/bin", "/sbin", "/lib", "/opt", "/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "~/.ssh", "~/.gnupg", "~/.aws", "~/.config"]
require_approval_for_medium_risk = true
shell_env_passthrough = []
workspace_only = true
[risk_profiles.field_ops]
level = "supervised"
allowed_tools = ["i2cdetect", "sysfs_led", "file_read", "content_search", "network"]
auto_approve = ["i2cdetect", "sysfs_led", "file_read", "content_search", "network"]
allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep", "find", "echo", "pwd", "wc", "head", "tail", "date", "df", "du", "uname", "uptime", "hostname", "python", "python3", "pip", "node", "free"]
allowed_roots = []
always_ask = []
block_high_risk_commands = true
excluded_tools = []
firejail_args = []
forbidden_paths = ["/etc", "/root", "/home", "/usr", "/bin", "/sbin", "/lib", "/opt", "/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "~/.ssh", "~/.gnupg", "~/.aws", "~/.config"]
require_approval_for_medium_risk = true
shell_env_passthrough = []
workspace_only = true
[risk_profiles.field_flash]
level = "supervised"
allowed_tools = ["i2cdetect", "sysfs_led", "file_read", "content_search", "network", "uno_q_flash"]
auto_approve = ["i2cdetect", "sysfs_led", "file_read", "content_search", "network", "uno_q_flash"]
allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep", "find", "echo", "pwd", "wc", "head", "tail", "date", "df", "du", "uname", "uptime", "hostname", "python", "python3", "pip", "node", "free"]
allowed_roots = []
always_ask = []
block_high_risk_commands = true
excluded_tools = []
firejail_args = []
forbidden_paths = ["/etc", "/root", "/home", "/usr", "/bin", "/sbin", "/lib", "/opt", "/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "~/.ssh", "~/.gnupg", "~/.aws", "~/.config"]
require_approval_for_medium_risk = true
shell_env_passthrough = []
workspace_only = true
[risk_profiles.demo]
level = "supervised"
allowed_tools = ["matrix_pattern", "i2cdetect"]
auto_approve = ["matrix_pattern", "i2cdetect"]
allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep", "find", "echo", "pwd", "wc", "head", "tail", "date", "df", "du", "uname", "uptime", "hostname", "python", "python3", "pip", "node", "free"]
allowed_roots = []
always_ask = []
block_high_risk_commands = true
excluded_tools = []
firejail_args = []
forbidden_paths = ["/etc", "/root", "/home", "/usr", "/bin", "/sbin", "/lib", "/opt", "/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "~/.ssh", "~/.gnupg", "~/.aws", "~/.config"]
require_approval_for_medium_risk = true
shell_env_passthrough = []
workspace_only = true
[agents.demo]
enabled = false
model_provider = "anthropic.max"
risk_profile = "default"
runtime_profile = "unoq"
skill_bundles = ["unoq"]
mcp_bundles = []
channels = ["telegram.default"]
acp_enable_mcp = false
classifier_provider = ""
cron_jobs = []
delegate_same_risk_profile = true
delegates = []
knowledge_bundles = []
summary_provider = ""
transcription_provider = ""
tts_provider = ""
[channels.telegram.default]
# Ships DISABLED: a tokenless channel would fail its getUpdates startup probe
# every 5s ("Startup probe: API error"), and that noise leaks into the module
# chat. The Telegram setup wizard flips this to enabled + a real bot_token when
# a team opts in (see api/src/nodes.ts configureTelegram).
enabled = false
bot_token = ""
api_base_url = "https://api.telegram.org"
approval_timeout_secs = 120
draft_update_interval_ms = 1000
excluded_tools = []
interrupt_on_new_message = false
mention_only = false
reply_min_interval_secs = 0
reply_queue_depth_max = 0
stream_mode = "off"
# All Uno Q hardware skills (led-matrix, uno-q-hardware, flashing, sketch-patterns,
# modulino, bridge, i2c/spi, ADXL355 context…) loaded onto every agent so it knows
# the board it is on and how to interface with the onboard devices.
[skill_bundles.unoq]
directory = "shared/skills"
include = []
exclude = []
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# package-onboard-app.sh — assemble a SELF-CONTAINED, distributable App Lab app
# ("APESS Onboard") that a student imports and clicks Run. Everything is baked in:
# the ZeroClaw binary, the single-agent config, the cloud token, the skills, and
# the resident responder sketch. No adb, no host install, no per-board setup.
#
# The instructor runs this ONCE to produce the bundle, then shares it via the App
# Lab UI (share → QR). Students scan the QR to import, open the app, click Run:
# the node comes up in one container, flashes the responder, and self-registers to
# the team's APESS laptop.
#
# Usage:
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-… # baked into the app
# ./deploy/uno-q/package-onboard-app.sh
#
# Env:
# ANTHROPIC_OAUTH_TOKEN (required) cloud Max token, baked into .zeroclaw/oauth_token
# ZEROCLAW_BIN aarch64 binary (default: the built release-fast one)
# APESS_URL where the board self-registers (default: mDNS apess-api.local)
# FLEET_SECRET shared fleet secret (default: apess2026)
# OUT output bundle dir (default: deploy/uno-q/dist/apess-onboard)
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
NODE_SRC="${NODE_SRC:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}"
ZEROCLAW_BIN="${ZEROCLAW_BIN:-$HOME/projects/zeroclaw/target/aarch64-unknown-linux-gnu/release-fast/zeroclaw}"
APESS_URL="${APESS_URL:-http://apess-api.local:3000}"
FLEET_SECRET="${FLEET_SECRET:-apess2026}"
OUT="${OUT:-$HERE/dist/apess-onboard}"
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; exit 1; }
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || bad "ANTHROPIC_OAUTH_TOKEN not set (it gets baked into the app)"
[ -f "$ZEROCLAW_BIN" ] || bad "binary not found: $ZEROCLAW_BIN (build it first)"
[ -f "$NODE_SRC/app.yaml" ] || bad "node app source not found: $NODE_SRC"
[ -f "$HERE/onboard-app/config.toml" ] || bad "canonical config missing: onboard-app/config.toml"
echo "→ assembling the bundle at $OUT"
rm -rf "$OUT"
mkdir -p "$OUT/bin" "$OUT/.zeroclaw/shared"
# App Lab app scaffold (manifest + entrypoint + resident sketch)
cp "$NODE_SRC/app.yaml" "$OUT/app.yaml"
cp -r "$NODE_SRC/python" "$OUT/python"
cp -r "$NODE_SRC/sketch" "$OUT/sketch"
ok "app.yaml + python + sketch (responder w/ i2c_scan)"
# The ZeroClaw binary (matrix_text + i2c_scan + the works)
install -m755 "$ZEROCLAW_BIN" "$OUT/bin/zeroclaw"
ok "binary ($(du -h "$OUT/bin/zeroclaw" | cut -f1))"
# Single-agent config (proven: anthropic.max, matrix + i2c_scan allowlisted,
# telegram-ready, secrets stripped so a fresh board mints its own .secret_key)
cp "$HERE/onboard-app/config.toml" "$OUT/.zeroclaw/config.toml"
ok "config (single 'default' agent, matrix + i2c_scan)"
# Skills — the resident copy the daemon seeds each agent's workspace from
cp -r "$HERE/skills" "$OUT/.zeroclaw/shared/skills"
ok "skills ($(ls "$HERE/skills" | wc -l | tr -d ' ') bundles)"
# Host-setup helpers that CAN'T ride the container: the participant-workspace
# bind-mount (root, before app start) that exposes ~/sketches + ~/ArduinoApps +
# libraries at /app/workspace so the agent can fix student code. App Lab import
# can't run these (no root), so they travel in host-setup/ for a one-time enable.
mkdir -p "$OUT/host-setup/systemd"
cp "$HERE/mount-user-workspace.sh" "$OUT/host-setup/mount-user-workspace.sh"
cp "$HERE/systemd/apess-user-workspace.service" "$OUT/host-setup/systemd/apess-user-workspace.service"
cp "$HERE/USER-WORKSPACE.md" "$OUT/host-setup/README.md"
chmod +x "$OUT/host-setup/mount-user-workspace.sh"
ok "host-setup/ (participant-workspace mount — enable once per board, needs root)"
# BAKED cloud token (per the workshop decision) — the instructor's Max token,
# shared across the fleet. Kept in the app bundle only, never in the repo.
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" > "$OUT/.zeroclaw/oauth_token"
chmod 600 "$OUT/.zeroclaw/oauth_token"
ok "cloud token baked in (.zeroclaw/oauth_token)"
# Self-register inputs. KIT_ID + CLAIM_CODE are per-board; the packaged defaults
# are placeholders the app regenerates a code from if unset. APESS_URL points the
# board at the team's laptop stack (default: mDNS name the deploy/lan box advertises).
cat > "$OUT/.zeroclaw/apess-node.env" <<EOF
KIT_ID=
CLAIM_CODE=
FLEET_SECRET=$FLEET_SECRET
APESS_URL=$APESS_URL
GATEWAY_PORT=8080
EOF
ok "apess-node.env (APESS_URL=$APESS_URL)"
# Embedded ZeroClaw dashboard (served at :8080/ — the "Open your agent" link in
# Phase 2). Built by `cargo xtask web build` into web/dist; config points
# web_dist_dir at /app/web-dist, so carry it there.
WEB_DIST="${WEB_DIST:-$HOME/projects/zeroclaw/web/dist}"
if [ -f "$WEB_DIST/index.html" ]; then
cp -r "$WEB_DIST" "$OUT/web-dist"; ok "web dashboard ($(du -sh "$WEB_DIST" | cut -f1))"
else
echo " (!) no dashboard at $WEB_DIST — build it: (cd zeroclaw && cargo xtask web build)."
echo " Without it, Phase 2's 'Open your agent' shows 'dashboard not available'."
fi
ok "bundle ready: $OUT"
# The App Lab-importable archive: a plain zip whose top dir is the app name.
# `arduino-app-cli app import <zip>` and the App Lab UI "Import an app" both
# accept it (verified round-trip). This is what we HOST for students to download.
ZIP="${ZIP:-$(dirname "$OUT")/$(basename "$OUT").zip}"
( cd "$(dirname "$OUT")" && rm -f "$ZIP" && zip -rq "$ZIP" "$(basename "$OUT")" -x '*.DS_Store' )
ok "import archive: $ZIP ($(du -h "$ZIP" | cut -f1))"
cat <<EOF
Distribute it:
• HOST for students: copy "$ZIP" to the production download path, e.g.
apess.redclaw.dev/download/apess-onboard.zip
Students download it, open App Lab → "Import an app" → pick the zip → Run.
• Instructor smoke-test on a board:
arduino-app-cli app import "$ZIP"
• Expose participant files to the agent (/app/workspace) — one-time, needs root
(App Lab import can't do this itself). On each board after import:
adb push <app>/host-setup/mount-user-workspace.sh /home/arduino/ && \\
adb shell 'chmod +x /home/arduino/mount-user-workspace.sh' && \\
adb push <app>/host-setup/systemd/apess-user-workspace.service /tmp/ && \\
adb shell 'sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \\
&& sudo systemctl enable --now apess-user-workspace.service \\
&& arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard'
Fleet boards: provision-fleet.sh does this automatically (MODE=systemd). See host-setup/README.md.
• APESS_URL: default is mDNS apess-api.local. Set per team by editing
.zeroclaw/apess-node.env before packaging, or pass APESS_URL=http://<laptop>:3000.
EOF
+27
View File
@@ -38,6 +38,16 @@ fi
provision() { # kit serial -> 0 ok / 1 fail
local kit="$1" serial="$2" env="$ENVDIR/$1.env"
[ -r "$env" ] || { echo " ! no env file for $kit ($env)"; return 1; }
# workshop WiFi so the board can reach the cloud brain (persisted by NetworkManager).
# Baked default is FabLab Torino; override with WIFI_SSID/WIFI_PASS. Best-effort.
if [ -x "$HERE/provision-wifi.sh" ]; then
if "$HERE/provision-wifi.sh" "$serial" >/dev/null 2>&1; then
echo " ok — WiFi joined (${WIFI_SSID:-Fablab_Torino})"
else
echo " ! WiFi join failed — check creds/coverage (agent cloud brain needs it)"
fi
fi
adb -s "$serial" shell 'mkdir -p /home/arduino/.zeroclaw' >/dev/null 2>&1 || return 1
adb -s "$serial" push "$env" /home/arduino/.zeroclaw/apess-node.env >/dev/null 2>&1 || return 1
adb -s "$serial" push "$HERE/apess-selfregister.sh" /home/arduino/ >/dev/null 2>&1 || return 1
@@ -77,6 +87,23 @@ provision() { # kit serial -> 0 ok / 1 fail
echo " ok — modalities (reload-watcher up; lockdown staged)"
fi
# participant workspace — bind ~/sketches + ~/ArduinoApps + ~/Arduino/libraries
# into the app container (/app/workspace) so the agent can read/fix student code.
# mount(2) is root-only and can't fall back to cron, so this is systemd-only,
# best-effort. See USER-WORKSPACE.md.
if [ -r "$HERE/mount-user-workspace.sh" ]; then
adb -s "$serial" push "$HERE/mount-user-workspace.sh" /home/arduino/ >/dev/null 2>&1
adb -s "$serial" shell 'chmod +x /home/arduino/mount-user-workspace.sh' >/dev/null 2>&1
adb -s "$serial" push "$HERE/systemd/apess-user-workspace.service" /tmp/ >/dev/null 2>&1
if adb -s "$serial" shell 'sudo -n cp /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo -n systemctl daemon-reload && sudo -n systemctl enable --now apess-user-workspace.service' >/dev/null 2>&1; then
adb -s "$serial" shell 'TMPDIR=/tmp arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard >/dev/null 2>&1 || true' >/dev/null 2>&1
echo " ok — participant workspace mounted (/app/workspace)"
else
echo " ! workspace mount needs root (sudo -n failed) — staged; enable apess-user-workspace.service on the board"
fi
fi
# default skills — install every bundled skill (the comprehensive arduino-uno-q
# reference + the fork's granular set) into every agent's workspace, so each
# node has them by default. Best-effort.
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# provision-host-daemon.sh — set up the ZeroClaw agent node as a HOST systemd
# service, NOT the App Lab container.
#
# WHY host, not container: the App Lab python-apps-base container can't actually
# drive the board. Proven on-hardware:
# • I2C/SPI: /dev is bind-mounted but the container's device cgroup blocks it
# (EPERM), and the image lacks i2cdetect.
# • Flashing: the image has no arduino-cli / Zephyr toolchain (uno_q_flash fails
# "arduino-cli not found").
# • /admin/reload: refused (the container publishes :8080 via NAT, so even
# host→localhost isn't loopback).
# On the host the daemon has native /dev, arduino-cli + the Zephyr toolchain, and
# loopback /admin/reload — everything the workshop needs.
#
# Usage (board on USB, cloud token in env):
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-…
# ./deploy/uno-q/provision-host-daemon.sh
#
# Env: SERIAL (65301572), BOARD_PW (sudo password), ZEROCLAW_BIN (built aarch64
# binary). Assumes the base board provision already populated ~/.zeroclaw
# (config.toml, .secret_key, agents, shared/skills) — see provision-uno-q.sh.
set -u
SERIAL="${SERIAL:-65301572}"
BOARD_PW="${BOARD_PW:-clouddev249}"
ZEROCLAW_BIN="${ZEROCLAW_BIN:-$HOME/projects/zeroclaw/target/aarch64-unknown-linux-gnu/release-fast/zeroclaw}"
UNIT_DIR="$(cd "$(dirname "$0")/systemd" && pwd)"
S(){ adb -s "$SERIAL" shell "$@"; }
# run a command as root on the board (sudo -S reads the password from stdin)
SU(){ adb -s "$SERIAL" shell "echo '$BOARD_PW' | sudo -S sh -c '$1'" 2>&1 | grep -iv 'password for'; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { bad "board $SERIAL not attached"; exit 1; }
[ -f "$ZEROCLAW_BIN" ] || { bad "binary not found: $ZEROCLAW_BIN (build it first)"; exit 1; }
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set (cloud brain)"; exit 1; }
echo "→ retire the App Lab container model (it can't reach the hardware)"
S "cd /home/arduino/ArduinoApps/zeroclaw-node/.cache 2>/dev/null && docker compose -f app-compose.yaml down 2>/dev/null; arduino-app-cli properties set default none 2>/dev/null" >/dev/null 2>&1
ok "App Lab container down + default app cleared (won't grab :8080 on boot)"
echo "→ deploy the host binary"
adb -s "$SERIAL" push "$ZEROCLAW_BIN" /tmp/zeroclaw.new >/dev/null
SU "install -m755 -o arduino -g arduino /tmp/zeroclaw.new /home/arduino/zeroclaw; rm -f /tmp/zeroclaw.new"
ok "binary → /home/arduino/zeroclaw"
echo "→ cloud credential env-file (raw token AND the config-override that wires api_key)"
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" | S "cat > /tmp/oat"
S "T=\$(cat /tmp/oat); { printf 'ANTHROPIC_OAUTH_TOKEN=%s\n' \"\$T\"; printf 'ZEROCLAW_providers__models__anthropic__max__api_key=%s\n' \"\$T\"; } > /home/arduino/.zeroclaw/daemon.env; chmod 600 /home/arduino/.zeroclaw/daemon.env; rm -f /tmp/oat"
ok "/home/arduino/.zeroclaw/daemon.env (0600, off the repo)"
echo "→ Arduino flashing prerequisite (the Zephyr core hard-requires this library)"
S "HOME=/home/arduino arduino-cli lib install Arduino_RouterBridge 2>&1 | tail -1"
ok "Arduino_RouterBridge installed"
echo "→ flash the resident matrix responder (enables instant matrix_text / matrix_pattern)"
# The responder sketch provides matrix_set/matrix_text over RouterBridge, so the
# agent can drive the matrix in <1s instead of compiling+flashing (~95s) — and
# without overwriting the MCU each time. One-time flash here.
RESPONDER_SKETCH="${RESPONDER_SKETCH:-$HOME/projects/zeroclaw/firmware/zeroclaw-node/sketch/sketch.ino}"
if [ -f "$RESPONDER_SKETCH" ]; then
S "rm -rf /tmp/responder && mkdir -p /tmp/responder/responder"
adb -s "$SERIAL" push "$RESPONDER_SKETCH" /tmp/responder/responder/responder.ino >/dev/null
S "HOME=/home/arduino arduino-cli compile --upload -b arduino:zephyr:unoq /tmp/responder/responder >/dev/null 2>&1 && echo flashed || echo 'responder flash failed'" | grep -q flashed \
&& ok "matrix responder flashed to the MCU" || bad "responder flash failed (matrix tools will need it)"
else
bad "responder sketch not found ($RESPONDER_SKETCH) — skipping (set RESPONDER_SKETCH)"
fi
echo "→ start the matrix relay container (:9999 → RouterBridge; the RouterBridge"
echo " python binding only ships in the App Lab image, so run JUST the relay there)"
S "mkdir -p /home/arduino/matrix-relay"
adb -s "$SERIAL" push "$(dirname "$0")/matrix-relay/relay.py" /home/arduino/matrix-relay/relay.py >/dev/null
S "docker rm -f matrix-relay >/dev/null 2>&1; docker run -d --name matrix-relay --restart unless-stopped -v /run/arduino-router.sock:/var/run/arduino-router.sock -v /home/arduino/matrix-relay/relay.py:/relay.py:ro -p 127.0.0.1:9999:9999 --entrypoint python3 ghcr.io/arduino/app-bricks/python-apps-base:0.11.0 /relay.py >/dev/null 2>&1 && echo up"
ok "matrix relay running on :9999"
echo "→ hardware device perms — udev rule so the agent can read I2C/SPI sensors"
SU "printf 'KERNEL==\"i2c-[0-9]*\", MODE=\"0666\"\nKERNEL==\"spidev[0-9]*\", MODE=\"0666\"\n' > /etc/udev/rules.d/99-apess-hw.rules; udevadm control --reload; udevadm trigger --subsystem-match=i2c-dev >/dev/null 2>&1"
ok "i2c/spi readable by the daemon user"
echo "→ install + enable the systemd service (boot-persistent)"
adb -s "$SERIAL" push "$UNIT_DIR/zeroclaw-daemon.service" /tmp/zeroclaw-daemon.service >/dev/null
SU "install -m644 /tmp/zeroclaw-daemon.service /etc/systemd/system/zeroclaw-daemon.service; rm -f /tmp/zeroclaw-daemon.service; systemctl daemon-reload; systemctl enable --now zeroclaw-daemon.service"
ok "zeroclaw-daemon.service enabled + started"
echo "→ verify the gateway comes up"
H=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
sleep 3
H=$(S "curl -s -m3 http://127.0.0.1:8080/health -o /dev/null -w '%{http_code}' 2>/dev/null")
[ "$H" = "200" ] && break
done
if [ "$H" = "200" ]; then
ok "gateway healthy on :8080"
echo "Done — node runs the ZeroClaw daemon on the host with full hardware access."
else
bad "gateway did not come up; inspect: adb -s $SERIAL shell 'sudo journalctl -u zeroclaw-daemon -n50'"
exit 1
fi
+54
View File
@@ -10,11 +10,13 @@
#
# Env: SERIAL (65301572), NODE_APP_DIR (repo app dir), plus the node-env vars above.
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
SERIAL="${SERIAL:-65301572}"
NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}"
DEST=/home/arduino/ArduinoApps/zeroclaw-node
S(){ adb -s "$SERIAL" shell "$@"; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn(){ printf ' \033[33m!\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { bad "board $SERIAL not attached"; exit 1; }
@@ -37,6 +39,36 @@ S "cp -f /home/arduino/.zeroclaw/.secret_key $DEST/.zeroclaw/.secret_key"
# web dashboard assets must live inside the app dir (/app in-container), not a host
# path — carry them in and repoint web_dist_dir so :8080/ serves the dashboard.
S "cp -rf /home/arduino/web-dist $DEST/web-dist 2>/dev/null; sed -i 's#^web_dist_dir = .*#web_dist_dir = \"/app/web-dist\"#' $DEST/.zeroclaw/config.toml"
# Telegram-ready guarantee — the wizard (dashboard "add a bot token") sets
# channels.telegram.default.bot_token + enabled, but that only starts a listener
# if the block exists AND agents.default subscribes to `telegram.default`. The
# config template ships both; re-assert them idempotently in case the carried
# config drifted, so every board is Telegram-ready out of the box.
S "python3 - $DEST/.zeroclaw/config.toml" <<'PY'
import re, sys
p = sys.argv[1]
with open(p) as f: t = f.read()
orig = t
# 1) agents.default must subscribe to telegram.default
m = re.search(r'(?ms)^\[agents\.default\][^\[]*', t)
if m:
sec = m.group(0)
if 'telegram.default' not in sec:
if re.search(r'(?m)^channels\s*=', sec):
sec2 = re.sub(r'(?m)^(channels\s*=\s*\[)([^\]]*)\]',
lambda x: f'{x.group(1)}{(x.group(2).strip()+", " ) if x.group(2).strip() else ""}"telegram.default"]', sec, count=1)
else:
sec2 = sec.rstrip() + '\nchannels = ["telegram.default"]\n'
t = t[:m.start()] + sec2 + t[m.end():]
# 2) the telegram.default channel block must exist (seeded disabled + empty)
if '[channels.telegram.default]' not in t:
t = t.rstrip() + '\n\n[channels.telegram.default]\nenabled = false\nbot_token = ""\napi_base_url = "https://api.telegram.org"\nmention_only = false\n'
if t != orig:
with open(p, 'w') as f: f.write(t)
print(' patched config → Telegram-ready')
else:
print(' config already Telegram-ready')
PY
# hardware skills — the [skill_bundles.unoq] bundle loads from shared/skills so the
# agent knows it's on an Uno Q and how to drive its devices. (copy skills/ directly,
# not the parent, to avoid cp nesting when the dir already exists.)
@@ -61,6 +93,28 @@ S "cd $DEST && TMPDIR=/tmp timeout 300 arduino-app-cli app start $DEST 2>&1 | ta
S 'crontab -l 2>/dev/null | grep -v "zeroclaw-supervisor" | crontab - 2>/dev/null; for p in $(pgrep -f "[z]eroclaw-supervisor"); do kill -9 $p 2>/dev/null; done'
ok "removed legacy supervisor @reboot cron (App Lab app owns boot now)"
echo "→ exposing participant files to the agent (/app/workspace)"
# Bind ~/sketches + ~/ArduinoApps + ~/Arduino/libraries under this app dir so the
# agent can read/fix student code. mount(2) is root-only and the App Lab /app bind
# is rprivate (submounts must precede the container), so this is a root oneshot
# ordered before arduino-app-cli.service. See USER-WORKSPACE.md.
adb -s "$SERIAL" push "$HERE/mount-user-workspace.sh" /home/arduino/mount-user-workspace.sh >/dev/null 2>&1
S "chmod +x /home/arduino/mount-user-workspace.sh"
adb -s "$SERIAL" push "$HERE/systemd/apess-user-workspace.service" /tmp/apess-user-workspace.service >/dev/null 2>&1
printf 'APP_DIR=%s\n' "$DEST" | S "cat > /home/arduino/.apess-workspace.env" # this app dir maps to /app
if S 'sudo -n cp /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo -n systemctl daemon-reload \
&& sudo -n systemctl enable --now apess-user-workspace.service' >/dev/null 2>&1; then
# rprivate: the running container must be recreated to pick up the new binds.
S "cd $DEST && TMPDIR=/tmp arduino-app-cli app restart $DEST >/dev/null 2>&1 || true"
ok "workspace mounted → agent sees ~/sketches, ~/ArduinoApps, ~/Arduino/libraries at /app/workspace"
else
warn "workspace mount needs root — sudo unavailable over adb. Files are staged; enable once on the board:"
echo " sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \\"
echo " && sudo systemctl enable --now apess-user-workspace.service \\"
echo " && arduino-app-cli app restart $DEST"
fi
echo "→ enable Run-at-startup for boot persistence:"
echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'"
ok "provisioned. In App Lab, open 'ZeroClaw Node' → Run."
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# provision-wifi.sh — join a Uno Q board to the workshop WiFi over adb and persist
# it. NetworkManager saves the connection profile, so the board auto-reconnects on
# every boot. The board reaches the cloud brain (api.anthropic.com) NAT'd out
# through this WiFi, so every workshop board needs it.
#
# The venue network is baked in as the default (override with WIFI_SSID/WIFI_PASS).
#
# Usage:
# ./provision-wifi.sh # first attached board
# ./provision-wifi.sh <adb-serial> # a specific board
# # every attached board at once:
# for s in $(adb devices | awk 'NR>1 && $2=="device"{print $1}'); do ./provision-wifi.sh "$s"; done
set -uo pipefail
# ── workshop WiFi (FabLab Torino) — override per venue with WIFI_SSID / WIFI_PASS ──
WIFI_SSID="${WIFI_SSID:-Fablab_Torino}"
WIFI_PASS="${WIFI_PASS:-Fablab.Torino!}"
S="${1:-$(adb devices 2>/dev/null | awk '/\tdevice$/{print $1; exit}')}"
[ -n "$S" ] || { echo "provision-wifi: no board attached over USB" >&2; exit 1; }
echo "==> [$S] joining WiFi '$WIFI_SSID'"
# Idempotent: if a saved profile already exists, just bring it up; otherwise scan
# and create it (a persistent NetworkManager profile that auto-reconnects on boot).
adb -s "$S" shell "nmcli radio wifi on >/dev/null 2>&1; sleep 1
if nmcli -t -f NAME connection show 2>/dev/null | grep -qx '$WIFI_SSID'; then
nmcli connection up '$WIFI_SSID'
else
nmcli device wifi rescan >/dev/null 2>&1; sleep 4
nmcli device wifi connect '$WIFI_SSID' password '$WIFI_PASS'
fi" 2>&1 | sed 's/^/ /'
# verify link + that the cloud is reachable through it
adb -s "$S" shell 'ip -brief addr show wlan0 2>/dev/null | sed "s/^/ wlan0: /"'
if adb -s "$S" shell 'getent hosts api.anthropic.com >/dev/null 2>&1'; then
echo " cloud DNS: resolves ✓ — board can reach the agent brain"
else
echo " cloud DNS: FAILS — check WiFi coverage / credentials" >&2
exit 1
fi
+114 -69
View File
@@ -1,81 +1,126 @@
#!/usr/bin/env bash
# recover.sh — one-command recovery for the APESS Uno Q demo node after a USB drop.
# recover.sh — restore the Uno Q board's LOCAL DEV binding after a USB re-plug.
#
# On a disconnect the daemon/llama/bridge die and the cloud token (env-only) is lost.
# This re-tunnels, relaunches the supervisor WITH the token in its environment,
# restarts the matrix bridge app, and verifies the whole chain end-to-end.
# On re-plug the Uno Q's Linux reboots and the laptop's adb tunnels vanish, so
# the local API (and the LED-matrix mirror) lose the board; the board also boots
# showing a fresh random claim code the API never received. This makes it all
# consistent again in one pass:
# 1. re-forward the adb tunnels (:8080 gateway, :9999 matrix relay)
# 2. assert the single-app invariant (canonical app is the boot default + the
# only thing on :8080/:9999; stop any stray duplicate)
# 3. wait for the node daemon (start the App Lab app if it isn't up)
# 4. re-register the node with the local API (restores its in-memory binding)
# 5. scroll a fixed claim code on the matrix so the board + API agree
#
# Secrets are read from the environment — NEVER hardcoded here. Export first:
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-... # required: cloud brain
# export NODE_TOKEN=zc_... # optional: end-to-end verify
# ./recover.sh
# Usage:
# ./recover.sh # one recovery pass, then exit
# ./recover.sh --watch # run forever: recover on every (re)connect
#
# Env knobs: SERIAL (default 65301572), the two tokens above.
set -u
# The App Lab node (apess-onboard) carries its own baked cloud token, so unlike
# the old host-daemon flow this needs NO secrets in the environment. Config via
# env (defaults suit the current dev board):
# SERIAL FLEET_SECRET KIT_ID CLAIM_CODE API_URL NODE_URL APP
set -uo pipefail
SERIAL="${SERIAL:-65301572}"
A(){ adb -s "$SERIAL" "$@"; }
S(){ adb -s "$SERIAL" shell "$@"; }
API="${API_URL:-http://127.0.0.1:3000}"
FLEET_SECRET="${FLEET_SECRET:-apess2026}"
KIT_ID="${KIT_ID:-crimson-node}"
CLAIM_CODE="${CLAIM_CODE:-7777}"
NODE_URL="${NODE_URL:-http://127.0.0.1:8080}"
APP="${APP:-/home/arduino/ArduinoApps/apess-onboard}"
CONTAINER="${APP##*/}-main-1" # App Lab names the container <app>-main-1
PORTS=(8080 9999)
# Resolve adb even under launchd's minimal PATH.
ADB="$(command -v adb || true)"
for c in /opt/homebrew/bin/adb /usr/local/bin/adb "$HOME/Library/Android/sdk/platform-tools/adb"; do
[ -n "$ADB" ] && break
[ -x "$c" ] && ADB="$c"
done
[ -n "$ADB" ] || { echo "recover: adb not found in PATH"; exit 127; }
log() { printf '\033[36m[recover]\033[0m %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
step(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
step "0· Preconditions"
if ! adb devices | grep -q "^${SERIAL}[[:space:]]*device"; then
bad "board $SERIAL not attached — re-plug USB, then re-run"; exit 1
adb_present() { "$ADB" devices | grep -q "^${SERIAL}[[:space:]].*device$"; }
matrix_code() {
python3 - "$CLAIM_CODE" <<'PY' 2>/dev/null
import socket, sys
s = socket.create_connection(('127.0.0.1', 9999), timeout=3)
s.sendall(f"text {sys.argv[1]}\n".encode()); s.recv(16); s.close()
PY
}
recover_once() {
adb_present || { warn "board $SERIAL not connected"; return 1; }
# 1 · re-forward tunnels (they vanish on re-plug)
for p in "${PORTS[@]}"; do
"$ADB" -s "$SERIAL" forward --list 2>/dev/null | grep -q "tcp:$p" \
|| "$ADB" -s "$SERIAL" forward "tcp:$p" "tcp:$p" >/dev/null
done
ok "tunnels forwarded (${PORTS[*]})"
# 2 · single-app invariant: the canonical node app owns :8080/:9999. Make it
# the boot default, stop any OTHER user app (a stray duplicate would collide on
# our ports), and ensure it's the running container.
"$ADB" -s "$SERIAL" shell "arduino-app-cli properties set default $APP" >/dev/null 2>&1
local strays
strays="$("$ADB" -s "$SERIAL" shell \
"docker ps --format '{{.Names}}' 2>/dev/null | grep -E '\-main-1$' | grep -v '^${CONTAINER}$'" 2>/dev/null | tr -d '\r')"
if [ -n "$strays" ]; then
warn "stopping stray app container(s): $strays"
for c in $strays; do
"$ADB" -s "$SERIAL" shell "docker stop $c" >/dev/null 2>&1 || true
done
fi
ok "board $SERIAL attached"
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set — cloud brain will fail. export it and re-run"; exit 1; }
ok "cloud token present in env"
step "1· Tunnel"
A forward tcp:8080 tcp:8080 >/dev/null && ok "adb forward :8080 → laptop localhost:8080"
step "2· Stop stale supervisor + daemons (preserve llama)"
S 'for p in $(ps -C zeroclaw-supervisor -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
for p in $(ps -C zeroclaw -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
rm -f /home/arduino/.zc-supervisor.lock; sleep 2
echo " daemons left: $(ps -C zeroclaw -o pid= 2>/dev/null | wc -l)"'
step "3· Relaunch supervisor WITH token env (env-only, never on disk)"
S "export ANTHROPIC_OAUTH_TOKEN='$ANTHROPIC_OAUTH_TOKEN'; \
export ZEROCLAW_providers__models__anthropic__max__api_key='$ANTHROPIC_OAUTH_TOKEN'; \
setsid nohup /home/arduino/zeroclaw-supervisor.sh >/dev/null 2>&1 </dev/null & sleep 2; echo done" >/dev/null
S 'pgrep -f "[z]eroclaw-supervisor" >/dev/null' && ok "supervisor relaunched" || bad "supervisor did NOT start"
step "4· Matrix bridge app (start only if down)"
if [ "$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')" = "pong" ]; then
ok "bridge already running"
else
S 'cd ~/ArduinoApps/uno-q-bridge && TMPDIR=/tmp arduino-app-cli app start ~/ArduinoApps/uno-q-bridge 2>&1 | tail -1'
if ! "$ADB" -s "$SERIAL" shell "docker ps --format '{{.Names}}'" 2>/dev/null | grep -q "^${CONTAINER}$"; then
warn "node app not running — starting ${APP##*/}"
"$ADB" -s "$SERIAL" shell "arduino-app-cli app start $APP" >/dev/null 2>&1 || true
fi
ok "single-app invariant (default + only ${APP##*/} on ${PORTS[*]})"
step "5· Wait for services"
for i in $(seq 1 30); do
L=$(S 'curl -sf -m3 http://127.0.0.1:8083/health >/dev/null 2>&1 && echo 1 || echo 0')
D=$(S 'curl -sf -m3 http://127.0.0.1:8080/health >/dev/null 2>&1 && echo 1 || echo 0')
printf '\r [%02d] llama=%s daemon=%s ' "$i" "$L" "$D"
[ "$D" = 1 ] && break; sleep 6
done; echo
[ "$L" = 1 ] && ok "llama :8083 healthy" || bad "llama :8083 DOWN (cold load can take 3–5 min; re-check)"
[ "$D" = 1 ] && ok "daemon :8080 healthy" || { bad "daemon :8080 DOWN"; exit 1; }
step "6· Bridge (matrix responder)"
P=$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')
[ "$P" = "pong" ] && ok "bridge :9999 responds (ping→pong)" || bad "bridge :9999 not responding — re-run step 4"
step "7· End-to-end: demo agent = cloud sonnet + matrix fires"
if [ -n "${NODE_TOKEN:-}" ]; then
S 'printf "matrix 0\n" | timeout 5 nc 127.0.0.1 9999 >/dev/null 2>&1'
R=$(curl -s -m 30 -X POST "http://127.0.0.1:8080/webhook?agent=demo" \
-H "Authorization: Bearer $NODE_TOKEN" -H 'Content-Type: application/json' \
-d '{"message":"Show the rain animation on the LED matrix"}')
echo "$R" | grep -q "claude-sonnet-5" && ok "agent=demo on claude-sonnet-5" || bad "agent NOT on sonnet — token may not have loaded: $R"
M=$(S "docker logs --since 40s uno-q-bridge-main-1 2>&1 | grep -c \"parts=\['matrix', '1'\]\"")
[ "${M:-0}" -ge 1 ] && ok "matrix_pattern fired (rain)" || bad "matrix did not change"
else
echo " (NODE_TOKEN unset — skipping authenticated end-to-end check)"
# 3 · wait for the node daemon (auto-starts on boot; start it if not)
local n=0
until curl -s -m2 "$NODE_URL/health" -o /dev/null 2>/dev/null; do
n=$((n + 1))
if [ "$n" -eq 20 ]; then
warn "daemon not up after ~40s — starting the app"
"$ADB" -s "$SERIAL" shell "arduino-app-cli app start $APP" >/dev/null 2>&1 || true
fi
if [ "$n" -gt 90 ]; then warn "daemon never came up ($NODE_URL/health)"; return 1; fi
sleep 2
done
ok "node daemon healthy"
step "Recovery complete."
echo " Voice proxy (laptop): if it was running it auto-recovers via the re-armed tunnel."
echo " If not running: NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=\$NODE_TOKEN python3 deploy/voice-client/serve.py 8090"
# 4 · re-register with the local API (restores the in-memory node binding)
local r
r="$(curl -s -m5 -X POST "$API/nodes/self-register" \
-H "x-fleet-secret: $FLEET_SECRET" -H 'content-type: application/json' \
-d "{\"kitId\":\"$KIT_ID\",\"claimCode\":\"$CLAIM_CODE\",\"url\":\"$NODE_URL\",\"token\":\"open-lan\"}" 2>/dev/null)"
if echo "$r" | grep -q '"url"'; then ok "re-registered with API (code $CLAIM_CODE)"
else warn "API self-register failed — is the API up at $API? ($r)"; return 1; fi
# 5 · sync the claim code onto the matrix so board + API agree
matrix_code && ok "matrix showing $CLAIM_CODE" || warn "could not set matrix code (relay :9999)"
log "recovered — bind in the UI with code $CLAIM_CODE"
}
watch_loop() {
log "watching board $SERIAL — recover on every (re)connect (Ctrl-C to stop)"
while true; do
"$ADB" -s "$SERIAL" wait-for-device
sleep 3 # let Linux + the App Lab app finish booting
recover_once || warn "recovery pass incomplete; will retry on next reconnect"
while adb_present; do sleep 2; done
log "board disconnected — waiting for re-plug"
done
}
case "${1:-}" in
--watch | -w) watch_loop ;;
*) recover_once ;;
esac
+24
View File
@@ -8,6 +8,30 @@ description: Drive the Arduino Uno Q's built-in 13x8 blue LED matrix — draw fr
The Uno Q has a built-in **13 columns × 8 rows** (104-pixel) **blue** LED matrix,
driven by the STM32U585 MCU. (It is NOT an LCD, and NOT red.)
## Instant runtime control — USE THIS FIRST (no flashing)
The board runs a **resident responder sketch** that already drives the matrix, so
you can change what it shows **instantly** with these tools — no sketch, no
compile, no flash:
- **`matrix_text`** — scroll a short message. Use for ANY "scroll / show / display
/ print `<text>`" request (e.g. `text="GO CLAWS"`).
- **`matrix_pattern`** — switch to a preset animation: `off, rain, heart, wave,
sparkle, checker, solid, blink`.
- **`matrix_count`** — count `0..N` on the matrix, **one number per second**. Use
for ANY "count to N / count up / print the numbers 0..N once a second" request
(e.g. `n=100`). The MCU runs the timed loop itself, so this is a single instant
call — do **NOT** write and flash a counting sketch. Flashing a timed loop is the
wrong tool: it takes ~90s, it fails inside the App Lab container, and it
overwrites the resident responder.
**Always reach for these tools first** for text, a preset animation, or a count. They take
effect in under a second. Do **NOT** write and flash a sketch for these — flashing
takes ~90s **and overwrites the resident responder, breaking `matrix_text` /
`matrix_pattern` until it's re-flashed.** Only write + flash a sketch (below) for a
**custom** frame pattern the tools can't produce, and know it replaces the
responder.
## Prefer the frame API (always available)
`Arduino_LED_Matrix` is bundled with the `arduino:zephyr` core — **no lib install**.
+2 -2
View File
@@ -7,7 +7,7 @@ description: Plug-and-play Modulino sensors/actuators for the Arduino Uno Q —
Modulinos are Arduino's Qwiic (I²C) plug-and-play modules. Chain them into the
Uno Q's **Qwiic connector (I2C4)** — no soldering, no pull-ups to add. Use the
`Modulino` Arduino library on the MCU, or scan the bus with the `i2cdetect` tool.
`Modulino` Arduino library on the MCU, or scan the bus with the `i2c_scan` tool.
## Default I²C addresses
@@ -32,7 +32,7 @@ void loop() { int v = knob.get(); /* ... */ }
## Tips
- Confirm a module is present with `i2cdetect` (scan the Qwiic bus) — the address
- Confirm a module is present with `i2c_scan` (scan the Qwiic/I2C bus on the MCU) — the address
should match the table above.
- Qwiic is 3.3 V, like the rest of the Arduino headers.
- The Movement Modulino (0x6A) is an IMU — handy for the workshop's sense loop.
@@ -32,6 +32,42 @@ Sketches always target the MCU (`arduino:zephyr:unoq`).
- `analogRead()` returns 0–1023; volts = `raw * 3.3 / 1023.0`.
- For a 5 V sensor, divide down: 5 V → 10 kΩ → A0 → 20 kΩ → GND.
## Checking sensors — the `i2c_scan` tool (mux-aware)
To see what's wired to the board's I2C, call **`i2c_scan`**. It probes the MCU's
Arduino Wire bus (Qwiic + I2C headers) — this is where student sensors hang, NOT
Linux `/dev/i2c-*` (those are MPU-side and unreachable from the app container).
The APESS kit hangs its ADXL355s behind a **PCA9548A I2C mux at `0x70`**, and two
sensors can share address `0x1d` on different channels — so the scan walks the mux
too. Read the comma-separated result like this:
- `0x1d` — a device directly on the bus (e.g. a lone ADXL355 wired to Qwiic).
- `0x70:mux` — an I2C mux is present at `0x70`.
- `0x70.2=0x1d` — a device at `0x1d` behind mux `0x70` on **channel 2**.
- `none` — nothing ACKed.
So `0x70:mux,0x70.2=0x1d,0x70.5=0x1d` = the mux plus two ADXL355s, one on channel 2
and one on channel 5. If a student sees only `0x70:mux`, their sensors aren't wired
to the mux channels (or aren't powered) — a mux with nothing behind it. If they see
nothing at all, check power and SDA/SCL. **ADXL355** = `0x1d` (or `0x1e` if ADDR is
pulled high); the FabLab kit reads it at `0x1d`.
## The student's own files — help fix their implementation
The participant's Arduino work is mounted into this container under **`/app/workspace/`**:
- `/app/workspace/sketches/<name>/<name>.ino` — sketches they wrote in App Lab's
sketch editor (this is where most implementations live).
- `/app/workspace/apps/<name>/` — full App Lab apps they built.
- `/app/workspace/libraries/` — installed Arduino libraries (read-only reference).
Read these to review, correct, and complete a student's code when they ask for help
("why doesn't my sensor read?", "fix my sketch"). You can edit files under
`sketches/` and `apps/`; `libraries/` is reference only. If `/app/workspace/` is
empty, the workspace mounts aren't set up on this board yet — say so rather than
guessing at their code.
## On-board LEDs
- RGB LED 1/2 are MPU-owned (`/sys/class/leds/*`, use the `sysfs_led` tool).
@@ -0,0 +1,21 @@
[Unit]
Description=APESS — bind participant workspace into the apess-onboard app container
# The app's /app bind is rprivate, so these submounts must exist BEFORE the App Lab
# daemon starts the default app container. Order strictly before it.
Before=arduino-app-cli.service
After=home-arduino.mount local-fs.target
RequiresMountsFor=/home/arduino
[Service]
Type=oneshot
RemainAfterExit=yes
# Which app dir maps to /app. Default = the distributable apess-onboard; the dev
# provisioner overrides it (to zeroclaw-node) by writing .apess-workspace.env.
Environment=APP_DIR=/home/arduino/ArduinoApps/apess-onboard
EnvironmentFile=-/home/arduino/.apess-workspace.env
ExecStart=/home/arduino/mount-user-workspace.sh
# Clean unmount on stop so the next start rebinds fresh.
ExecStop=/bin/sh -c 'for m in "$APP_DIR"/workspace/sketches "$APP_DIR"/workspace/libraries "$APP_DIR"/workspace/apps/*; do mountpoint -q "$m" && umount "$m" || true; done; exit 0'
[Install]
WantedBy=multi-user.target
@@ -6,6 +6,12 @@ Wants=zeroclaw-llama.service
[Service]
Type=simple
User=arduino
WorkingDirectory=/home/arduino
# Cloud credential — kept off-disk in the repo; provisioned into this env file on
# the board. Sets BOTH the raw token and the ZeroClaw config-override that wires
# providers.models.anthropic.max.api_key (the config TOML carries no key). The
# leading `-` makes it non-fatal if the file is absent (offline/local agent).
EnvironmentFile=-/home/arduino/.zeroclaw/daemon.env
# Use `daemon`, NOT `gateway start`: only the daemon/agent/channel arms register
# the peripheral tools (uno_q_flash etc.). `gateway start` on an older build
# leaves the agent unable to flash.
+4 -2
View File
@@ -6,16 +6,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="APESS 2026 Workshop — on-device agentic systems for structural intelligence. FabLab Torino, July 27." />
<title>APESS 2026 · Workshop</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Newsreader:wght@400;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Newsreader:wght@400;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap"
/>
</noscript>
</head>
+3
View File
@@ -16,6 +16,7 @@
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.5",
"@xyflow/react": "^12.11.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
@@ -23,6 +24,7 @@
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0",
"tailwind-merge": "^3.6.0",
"three": "0.160.0",
"zustand": "^5.0.14"
},
"devDependencies": {
@@ -33,6 +35,7 @@
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "0.160.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.8",
"autoprefixer": "^10.5.0",
+237 -2
View File
@@ -11,6 +11,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.5
version: 1.2.5(@types/[email protected])([email protected])
'@xyflow/react':
specifier: ^12.11.2
version: 12.11.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -32,9 +35,12 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
three:
specifier: 0.160.0
version: 0.160.0
zustand:
specifier: ^5.0.14
version: 5.0.14(@types/[email protected])([email protected])
version: 5.0.14(@types/[email protected])([email protected])([email protected]([email protected]))
devDependencies:
'@eslint/js':
specifier: ^10.0.1
@@ -57,6 +63,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/[email protected])
'@types/three':
specifier: 0.160.0
version: 0.160.0
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.2([email protected](@types/[email protected])([email protected]))
@@ -509,6 +518,24 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/[email protected]':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/[email protected]':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/[email protected]':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/[email protected]':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/[email protected]':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/[email protected]':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -532,6 +559,15 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/[email protected]':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/[email protected]':
resolution: {integrity: sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==}
'@types/[email protected]':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@typescript-eslint/[email protected]':
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -638,6 +674,22 @@ packages:
'@vitest/[email protected]':
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
'@xyflow/[email protected]':
resolution: {integrity: sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==}
peerDependencies:
'@types/react': '>=17'
'@types/react-dom': '>=17'
react: '>=17'
react-dom: '>=17'
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@xyflow/[email protected]':
resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==}
[email protected]:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -734,6 +786,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
[email protected]:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
[email protected]:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -768,6 +823,44 @@ packages:
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
[email protected]:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -914,6 +1007,9 @@ packages:
picomatch:
optional: true
[email protected]:
resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==}
[email protected]:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
@@ -1172,6 +1268,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
[email protected]:
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
[email protected]:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -1468,6 +1567,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
[email protected]:
resolution: {integrity: sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==}
[email protected]:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1550,6 +1652,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
[email protected]:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -1690,6 +1797,21 @@ packages:
[email protected]:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
[email protected]:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
[email protected]:
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
engines: {node: '>=12.20.0'}
@@ -2086,6 +2208,27 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/d3-selection': 3.0.11
'@types/[email protected]':
dependencies:
'@types/d3-color': 3.1.3
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/d3-selection': 3.0.11
'@types/[email protected]':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -2106,6 +2249,17 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
fflate: 0.6.11
meshoptimizer: 0.18.1
'@types/[email protected]': {}
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -2254,6 +2408,31 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xyflow/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@xyflow/system': 0.0.79
classcat: 5.0.5
react: 19.2.7
react-dom: 19.2.7([email protected])
zustand: 4.5.7(@types/[email protected])([email protected])
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/[email protected])
transitivePeerDependencies:
- immer
'@xyflow/[email protected]':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
[email protected]([email protected]):
dependencies:
acorn: 8.16.0
@@ -2345,6 +2524,8 @@ snapshots:
dependencies:
clsx: 2.1.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2370,6 +2551,42 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
[email protected]: {}
[email protected]:
dependencies:
d3-color: 3.1.0
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
[email protected]:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1([email protected])
[email protected]:
dependencies:
whatwg-mimetype: 5.0.0
@@ -2518,6 +2735,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -2729,6 +2948,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
braces: 3.0.3
@@ -3012,6 +3233,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3083,6 +3306,10 @@ snapshots:
dependencies:
punycode: 2.3.1
[email protected]([email protected]):
dependencies:
react: 19.2.7
[email protected]: {}
[email protected](@types/[email protected])([email protected]):
@@ -3167,7 +3394,15 @@ snapshots:
[email protected]: {}
zustand@5.0.14(@types/[email protected])([email protected]):
zustand@4.5.7(@types/[email protected])([email protected]):
dependencies:
use-sync-external-store: 1.6.0([email protected])
optionalDependencies:
'@types/react': 19.2.17
react: 19.2.7
[email protected](@types/[email protected])([email protected])([email protected]([email protected])):
optionalDependencies:
'@types/react': 19.2.17
react: 19.2.7
use-sync-external-store: 1.6.0([email protected])
Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

+19 -4
View File
@@ -3,24 +3,39 @@ import { Landing } from '@/pages/Landing'
import { TeamRegistration } from '@/pages/TeamRegistration'
import { Lecture } from '@/pages/Lecture'
import { EnvSetup } from '@/pages/EnvSetup'
import { Module1 } from '@/pages/Module1'
import { Module2 } from '@/pages/Module2'
import { ModuleMakeup } from '@/pages/ModuleMakeup'
import { ModuleDashboard } from '@/pages/ModuleDashboard'
import { AddBuilder } from '@/pages/AddBuilder'
import { Admin } from '@/pages/Admin'
import { Judge } from '@/pages/Judge'
import { CockpitLayout } from '@/components/cockpit/CockpitLayout'
import { ProceedProvider } from '@/lib/ProceedContext'
import { useCollectiveSync } from '@/lib/useCollectiveSync'
import { useApplyTheme } from '@/lib/useApplyTheme'
export default function App() {
useCollectiveSync()
useApplyTheme()
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
{/* The workshop flow runs inside the persistent cockpit shell. The
ProceedProvider lets each phase publish its advance button into the
shell's sidebar. Module 1 = Skills & policies, Module 2 = UnoQ Dashboard. */}
<Route
element={
<ProceedProvider>
<CockpitLayout />
</ProceedProvider>
}
>
<Route path="/workshop" element={<TeamRegistration />} />
<Route path="/workshop/setup" element={<EnvSetup />} />
<Route path="/workshop/module1" element={<Module1 />} />
<Route path="/workshop/module2" element={<Module2 />} />
<Route path="/workshop/module1" element={<ModuleMakeup />} />
<Route path="/workshop/module2" element={<ModuleDashboard />} />
<Route path="/workshop/add" element={<AddBuilder />} />
</Route>
<Route path="/lecture" element={<Lecture />} />
<Route path="/admin" element={<Admin />} />
<Route path="/judge" element={<Judge />} />
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, act, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentChat } from './AgentChat'
import { useSession } from '@/store/session'
import { sendPrompt } from '@/lib/api'
import type { WsEvent } from '@/types'
let emit: (e: WsEvent) => void = () => {}
vi.mock('@/lib/api', () => ({
AGENT: 'default',
sendPrompt: vi.fn().mockResolvedValue(undefined),
openTeamActivity: (_t: string, on: (e: WsEvent) => void) => {
emit = on
return () => {}
},
}))
const mockSend = vi.mocked(sendPrompt)
describe('AgentChat', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
mockSend.mockClear()
mockSend.mockResolvedValue(undefined)
})
it('sends the canned prompt and shows it in the transcript', async () => {
const user = userEvent.setup()
render(<AgentChat />)
await user.click(screen.getByTestId('prompt-i2c'))
expect(mockSend).toHaveBeenCalledWith(expect.any(String), 'List the I2C devices on the bus', 'default')
expect(screen.getByTestId('chat-transcript')).toHaveTextContent(/list the i2c devices/i)
expect(screen.getByTestId('prompt-i2c')).toHaveAttribute('data-state', 'running')
})
it('marks a prompt done on a terminal activity and reports progress', async () => {
const onProgress = vi.fn()
const user = userEvent.setup()
render(<AgentChat onProgress={onProgress} />)
await user.click(screen.getByTestId('prompt-scroll'))
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'flash', label: 'Flashed to 0x80F0000', ts: '' }))
await waitFor(() => expect(screen.getByTestId('prompt-scroll')).toHaveAttribute('data-state', 'done'))
expect(onProgress).toHaveBeenLastCalledWith(1, 3)
})
it('an error resets the prompt so it can be retried', async () => {
const user = userEvent.setup()
render(<AgentChat />)
await user.click(screen.getByTestId('prompt-count'))
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'error', label: 'boom', ts: '' }))
await waitFor(() => expect(screen.getByTestId('prompt-count')).toHaveAttribute('data-state', 'idle'))
expect(screen.getByTestId('prompt-count')).toBeEnabled()
})
})
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity, AGENT } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types'
/**
* The three canned prompts, in order. Each drives the agent to use its skills +
* tools on the real board: enumerate the I2C bus, then two LED-matrix sketches.
* KEEP THESE IMPERATIVE — the small on-board model reliably calls tools when told
* to *do* something but stalls when *asked a question* (measured on the board).
*/
const PROMPTS: { id: string; text: string }[] = [
{ id: 'i2c', text: 'List the I2C devices on the bus' },
{ id: 'count', text: 'Count to 100 and print the value once a second in the LED matrix' },
{ id: 'scroll', text: 'Scroll GO CLAWS on the LED matrix' },
]
type Status = 'idle' | 'running' | 'done'
type Line =
| { who: 'you'; text: string }
| { who: 'agent'; kind: NodeActivityKind; text: string }
const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground',
tool: 'bg-amber',
flash: 'bg-primary',
error: 'bg-destructive',
response: 'bg-teal',
fallback: 'bg-rose',
}
// A prompt is "done" once the agent reaches a terminal step for it.
const isSuccess = (k: NodeActivityKind) => k === 'flash' || k === 'response'
export interface AgentChatProps {
/** Called whenever the set of successfully-tried prompts changes. */
onProgress?: (doneCount: number, total: number) => void
}
/**
* A chat window straight to the team's agent. Click a canned prompt and watch
* the agent use its skills/tools on the real board — its activity streams back
* as the reply. Tracks which of the three prompts have completed successfully.
*/
export function AgentChat({ onProgress }: AgentChatProps) {
const teamId = useSession((s) => s.teamId)
const [lines, setLines] = useState<Line[]>([])
const [status, setStatus] = useState<Record<string, Status>>({ i2c: 'idle', count: 'idle', scroll: 'idle' })
const running = useRef<string | null>(null)
const scroller = useRef<HTMLDivElement>(null)
const doneCount = Object.values(status).filter((s) => s === 'done').length
const busy = running.current !== null || Object.values(status).some((s) => s === 'running')
// Report progress without making the parent's inline callback a dependency
// (that would re-fire every render and loop with the parent's setState).
const onProgressRef = useRef(onProgress)
onProgressRef.current = onProgress
useEffect(() => {
onProgressRef.current?.(doneCount, PROMPTS.length)
}, [doneCount])
useEffect(() => {
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
}, [lines])
// Stream this team's own board activity as the agent's replies.
useEffect(() => {
return openTeamActivity(teamId, (ev: WsEvent) => {
if (ev.type !== 'node:activity') return
setLines((prev) => [...prev, { who: 'agent', kind: ev.kind, text: ev.label }])
const active = running.current
if (!active) return
if (ev.kind === 'error') {
running.current = null
setStatus((s) => ({ ...s, [active]: 'idle' })) // let them retry
} else if (isSuccess(ev.kind)) {
running.current = null
setStatus((s) => ({ ...s, [active]: 'done' }))
}
})
}, [teamId])
const run = async (id: string, text: string) => {
if (busy) return
running.current = id
setStatus((s) => ({ ...s, [id]: 'running' }))
setLines((prev) => [...prev, { who: 'you', text }])
try {
await sendPrompt(teamId, text, AGENT)
} catch {
running.current = null
setStatus((s) => ({ ...s, [id]: 'idle' }))
setLines((prev) => [
...prev,
{ who: 'agent', kind: 'error', text: 'Could not reach your agent — is the board online?' },
])
}
}
return (
<Card data-testid="agent-chat">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Chat with your agent</CardTitle>
<Badge variant="default" className="font-mono text-[10px] uppercase tracking-widest">
{doneCount}/{PROMPTS.length} tried
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Send one of these to your agent and watch it use its skills and tools on the real board —
its activity streams back here. Try all three to see what it can already do.
</p>
{/* canned prompts */}
<div className="flex flex-col gap-2">
{PROMPTS.map((p) => {
const st = status[p.id]
return (
<button
key={p.id}
type="button"
data-testid={`prompt-${p.id}`}
data-state={st}
disabled={busy && st !== 'running'}
onClick={() => void run(p.id, p.text)}
className={cn(
'flex items-center gap-2.5 text-left rounded-md border px-3 py-2 text-sm transition-colors disabled:opacity-50',
st === 'done'
? 'border-teal/40 bg-teal/10'
: st === 'running'
? 'border-amber/40 bg-amber/5'
: 'border-border hover:border-primary/40 hover:bg-muted',
)}
>
<span
className={cn(
'w-2 h-2 rounded-full shrink-0',
st === 'done' ? 'bg-teal' : st === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground/50',
)}
/>
<span className="flex-1">{p.text}</span>
<span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground shrink-0">
{st === 'done' ? 'done ✓' : st === 'running' ? 'running…' : 'send →'}
</span>
</button>
)
})}
</div>
{/* transcript */}
<div
ref={scroller}
data-testid="chat-transcript"
className="rounded-md border border-border bg-background/50 p-3 h-56 overflow-y-auto space-y-2"
>
{lines.length === 0 ? (
<p className="font-mono text-[11px] text-muted-foreground">
Pick a prompt above — your agent&rsquo;s work (tools, flashes, replies) shows up here.
</p>
) : (
lines.map((l, i) =>
l.who === 'you' ? (
<div key={i} className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[85%]">{l.text}</span>
</div>
) : (
<div key={i} className="flex items-start gap-2 font-mono text-[11px]">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0 mt-1.5', KIND_DOT[l.kind])} />
<span className={cn(l.kind === 'error' && 'text-destructive', l.kind === 'flash' && 'text-foreground font-medium')}>
{l.text}
</span>
</div>
),
)
)}
</div>
</CardContent>
</Card>
)
}
+30 -18
View File
@@ -10,18 +10,22 @@ export interface BoardClaimProps {
connected: boolean
port: string | null
onClaimed: (result: ClaimResult) => void
/** Drop the binding so the team can re-bind (only on an explicit click). */
onDisconnect?: () => void
/** Pre-fill the code (e.g. from a ?code= param). */
initialCode?: string
}
/** The self-service bring-up steps — the board is already set up from the week. */
/** Self-service bring-up: download the App Lab app, import it, Run it, enter the code. */
const STEPS = [
'On your board, open a terminal and run the workshop setup script (below).',
'It checks your node is ready, registers it, and scrolls a code across the LED matrix.',
'Type the code your board is showing to bind it to your team.',
'Download the board app below.',
'On your Uno Q, open App Lab → “Import an app” → pick the downloaded file.',
'Click Run. The node comes up and scrolls a code across the LED matrix.',
'Type that code below to bind the board to your team.',
]
const SETUP_CMD = 'curl -fsSL https://apess.redclaw.dev/setup.sh | bash'
// Served same-origin from the web container (see deploy/nginx: location /download/).
const DOWNLOAD_URL = '/download/apess-onboard.zip'
/**
* The board bring-up wizard for pre-deployed devices: the attendee runs the
@@ -29,7 +33,7 @@ const SETUP_CMD = 'curl -fsSL https://apess.redclaw.dev/setup.sh | bash'
* code on its matrix; entering that code binds the board to the team. The
* bearer token never touches the browser.
*/
export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, initialCode }: BoardClaimProps) {
export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, onDisconnect, initialCode }: BoardClaimProps) {
const [code, setCode] = useState(initialCode ?? '')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -37,10 +41,21 @@ export function BoardClaim({ teamId, teamName, members, connected, port, onClaim
if (connected) {
return (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3" data-testid="board-connected">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" />
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="w-2 h-2 rounded-full bg-teal animate-pulse shrink-0" />
<span className="text-sm font-medium">Board claimed</span>
</div>
{onDisconnect && (
<button
type="button"
onClick={onDisconnect}
className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-red-500 shrink-0"
>
Disconnect
</button>
)}
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1">{port}</div>
</div>
)
@@ -71,17 +86,14 @@ export function BoardClaim({ teamId, teamName, members, connected, port, onClaim
</li>
))}
</ol>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<code className="font-mono text-[11px] text-foreground/90 select-all flex-1 truncate">{SETUP_CMD}</code>
<button
type="button"
aria-label="Copy setup command"
onClick={() => navigator.clipboard?.writeText(SETUP_CMD)}
className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground shrink-0"
<a
href={DOWNLOAD_URL}
download
data-testid="download-board-app"
className="flex items-center justify-center gap-2 rounded-md border border-primary/40 bg-primary/5 px-3 py-2.5 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
>
Copy
</button>
</div>
<span aria-hidden>↓</span> Download the board app
</a>
<div className="flex gap-2">
<Input
aria-label="Claim code"
+5 -2
View File
@@ -12,11 +12,14 @@ let liveOnEvent: ((e: WsEvent) => void) | null = null
const closeSpy = vi.fn()
vi.mock('@/lib/api', () => ({
AGENT: 'default',
sendPrompt: (...args: unknown[]) => sendPrompt(...args),
openTeamActivity: (_teamId: string, onEvent: (e: WsEvent) => void) => {
liveOnEvent = onEvent
return closeSpy
},
// OpenYourNode (rendered here) reads the runtime mode.
getMode: () => Promise.resolve({ localMode: false }),
}))
describe('BuildFlash', () => {
@@ -39,13 +42,13 @@ describe('BuildFlash', () => {
expect(screen.getByText(/live board/i)).toBeInTheDocument()
})
it('sends the prompt to the single cloud agent and renders streamed board activity', async () => {
it('sends the prompt to the single agent and renders streamed board activity', async () => {
const user = userEvent.setup()
render(<MemoryRouter><BuildFlash /></MemoryRouter>)
await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
await user.click(screen.getByRole('button', { name: /working|send/i }))
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO', 'cloud')
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO', 'default')
// a flash event streams in over the (mocked) SSE feed
act(() => {
+2 -2
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { OpenYourNode } from '@/components/OpenYourNode'
import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api'
import { sendPrompt, openTeamActivity, AGENT } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types'
interface Entry {
@@ -70,7 +70,7 @@ export function BuildFlash() {
setBusy(true)
try {
await sendPrompt(teamId, msg, 'cloud')
await sendPrompt(teamId, msg, AGENT)
} catch {
append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' })
setBusy(false)
+8 -42
View File
@@ -1,20 +1,12 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DomainPicker } from './DomainPicker'
import { useSession } from '@/store/session'
import { askNode } from '@/lib/api'
vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()),
askNode: vi.fn(),
}))
const mockAsk = vi.mocked(askNode)
describe('DomainPicker', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
mockAsk.mockReset()
})
it('binds the input to the session domain', () => {
@@ -24,41 +16,15 @@ describe('DomainPicker', () => {
expect(useSession.getState().domain).toBe('air quality')
})
it('shows generic scaffolding hints for the four next dimensions', () => {
it('reflects an already-named domain', () => {
useSession.getState().setDomain('structural stress')
render(<DomainPicker />)
for (const label of ['Skills', 'Policies', 'Harness', 'Loops']) {
expect(screen.getByText(label)).toBeInTheDocument()
}
expect(screen.getByLabelText(/your domain/i)).toHaveValue('structural stress')
})
it('disables Refine until a domain is named', () => {
it('is just the name — no refine / generation controls', () => {
render(<DomainPicker />)
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
fireEvent.change(screen.getByLabelText(/your domain/i), { target: { value: 'air quality' } })
expect(screen.getByRole('button', { name: /refine/i })).toBeEnabled()
})
it('refine drafts all four dimensions into the store and opens a modal', async () => {
mockAsk.mockImplementation(async (_t, prompt) => {
const which = /SKILLS/.test(prompt) ? 'skills'
: /POLICIES/.test(prompt) ? 'policies'
: /HARNESS/.test(prompt) ? 'harness' : 'loops'
return `draft for ${which}`
})
useSession.getState().setDomain('air quality')
render(<DomainPicker />)
fireEvent.click(screen.getByRole('button', { name: /refine/i }))
await waitFor(() => expect(useSession.getState().add.L2).toBe('draft for skills'))
expect(useSession.getState().add.L3).toBe('draft for policies')
expect(useSession.getState().add.L4).toBe('draft for harness')
expect(useSession.getState().add.L5).toBe('draft for loops')
// the Skills card is now done + clickable → clicking pops the modal
const skills = await screen.findByTestId('dim-L2')
expect(skills).toHaveAttribute('data-state', 'done')
fireEvent.click(skills)
expect(screen.getByRole('dialog')).toHaveTextContent(/draft for skills/i)
expect(screen.queryByRole('button', { name: /refine/i })).toBeNull()
expect(screen.queryByText('Skills')).toBeNull()
})
})
+6 -140
View File
@@ -1,89 +1,17 @@
import { useId, useState } from 'react'
import { useId } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Modal } from '@/components/ui/modal'
import { useSession, type AddLayers } from '@/store/session'
import { askNode } from '@/lib/api'
import { cn } from '@/lib/utils'
type LayerKey = 'L2' | 'L3' | 'L4' | 'L5'
type GenState = 'idle' | 'running' | 'done'
/** The four design dimensions the domain refine jump-starts, mapped to ADD layers.
* Each prompt is failure-first framed so the drafts seed the real deliverable. */
const DIMENSIONS: { key: LayerKey; label: string; hint: string; prompt: (d: string) => string }[] = [
{
key: 'L2',
label: 'Skills',
hint: 'What domain knowledge must it know?',
prompt: (d) =>
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe the SKILLS it needs — the domain knowledge and sensing/interpretation capabilities required to understand this domain. Plain prose, no preamble, no heading.`,
},
{
key: 'L3',
label: 'Policies',
hint: 'What may it do autonomously vs. need approval?',
prompt: (d) =>
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its POLICIES — what it may do autonomously vs. what needs human approval, and how each thing fails safe (a failure must never read as "nominal"; degrade to unknown/escalate). Plain prose, no preamble, no heading.`,
},
{
key: 'L4',
label: 'Harness',
hint: 'When does it decide locally vs. escalate?',
prompt: (d) =>
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its HARNESS — the degradation path (cloud → on-board → fully offline) and what still works with no network at all. Plain prose, no preamble, no heading.`,
},
{
key: 'L5',
label: 'Loops',
hint: 'How often does it check its world + report by exception?',
prompt: (d) =>
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its LOOPS — how often it checks its world, how it reports by exception, and what it does when a cycle fails (stale readings, missed ticks, partial data). Plain prose, no preamble, no heading.`,
},
]
import { useSession } from '@/store/session'
/**
* Names the problem domain and, via "Refine", asks the team's node to jump-start
* drafts for the four design dimensions (ADD layers L2–L5). Each card generates
* behind the scenes, turns green when done, and opens a modal with its draft.
* Drafts land in the session store so later modules pick them up.
* Names the problem domain the team is tackling. Saved to the session store so
* the modules ahead frame everything around it. Just the name — no generation.
*/
export function DomainPicker() {
const domain = useSession((s) => s.domain)
const setDomain = useSession((s) => s.setDomain)
const add = useSession((s) => s.add)
const setAddLayer = useSession((s) => s.setAddLayer)
const teamId = useSession((s) => s.teamId)
const inputId = useId()
const [state, setState] = useState<Record<LayerKey, GenState>>({ L2: 'idle', L3: 'idle', L4: 'idle', L5: 'idle' })
const [open, setOpen] = useState<LayerKey | null>(null)
const [error, setError] = useState<string | null>(null)
const anyRunning = Object.values(state).some((s) => s === 'running')
const doneCount = DIMENSIONS.filter((d) => state[d.key] === 'done').length
const refine = () => {
if (!domain.trim() || anyRunning) return
setError(null)
for (const dim of DIMENSIONS) {
setState((s) => ({ ...s, [dim.key]: 'running' }))
askNode(teamId, dim.prompt(domain))
.then((text) => {
setAddLayer(dim.key as keyof AddLayers, text.trim())
setState((s) => ({ ...s, [dim.key]: 'done' }))
})
.catch(() => {
setState((s) => ({ ...s, [dim.key]: 'idle' }))
setError('Could not reach your node — say hi first, then try again.')
})
}
}
const active = DIMENSIONS.find((d) => d.key === open)
return (
<div className="space-y-5">
<div className="space-y-2">
<label htmlFor={inputId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Your domain
@@ -95,71 +23,9 @@ export function DomainPicker() {
onChange={(e) => setDomain(e.target.value)}
/>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Name the domain your node is for and the events it senses — ideally the one you have been measuring
already. This frames everything you design next.
Name the domain your agent is for and the events it senses — ideally the one you have been
measuring already. This frames everything you design next.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
What you&rsquo;ll design next
</div>
<Button
size="sm"
variant="secondary"
className="h-7 px-2.5 font-mono text-[11px] tracking-wider"
disabled={!domain.trim() || anyRunning}
onClick={refine}
title="Draft all four dimensions from your domain"
>
{anyRunning ? `Refining… ${doneCount}/4` : doneCount > 0 ? '↻ Refine again' : '✦ Refine'}
</Button>
</div>
<div className="grid sm:grid-cols-2 gap-3">
{DIMENSIONS.map((d) => {
const st = state[d.key]
const content = add[d.key]
const clickable = st === 'done' && !!content
return (
<button
key={d.key}
type="button"
data-testid={`dim-${d.key}`}
data-state={st}
disabled={!clickable}
onClick={() => clickable && setOpen(d.key)}
className={cn(
'text-left rounded-md border px-3 py-2.5 transition-colors',
st === 'done'
? 'border-teal/40 bg-teal/10 hover:bg-teal/15 cursor-pointer'
: st === 'running'
? 'border-amber/40 bg-amber/5'
: 'border-border',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] uppercase tracking-widest text-primary">{d.label}</span>
{st === 'running' && <span className="w-1.5 h-1.5 rounded-full bg-amber animate-pulse" />}
{st === 'done' && <span className="font-mono text-[9px] uppercase tracking-widest text-teal">ready ✓</span>}
</div>
<div className="text-sm text-muted-foreground leading-snug mt-1 line-clamp-2">
{st === 'done' && content ? content : d.hint}
</div>
</button>
)
})}
</div>
{error && <p role="alert" className="text-xs text-red-500 leading-relaxed">{error}</p>}
</div>
<Modal open={open !== null} onClose={() => setOpen(null)} title={active ? `Draft · ${active.label}` : ''}>
<p className="text-sm leading-relaxed whitespace-pre-wrap text-foreground/90">{open ? add[open] : ''}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-4 leading-relaxed">
A starting draft from your node — refine it further in the modules ahead.
</p>
</Modal>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from 'react'
import { autoClaimLocal, disconnectLocalBoard, getNodeStatus, type ClaimResult } from '@/lib/api'
import { cn } from '@/lib/utils'
export interface LocalBoardConnectProps {
teamId: string
teamName: string
members: string[]
connected: boolean
port: string | null
/** Bind succeeded — parent stores the device (same handler as BoardClaim). */
onClaimed: (result: ClaimResult) => void
/** Drop the local device binding in the store. */
onDisconnect: () => void
}
/**
* Self-host / USB auto-connect (LOCAL_MODE). One private API + one board over USB,
* so there's no claim code: once the team is named and the board is detected, it
* binds automatically. A USB drop keeps the binding and reconnects on its own; an
* explicit Disconnect releases it and waits for a Reconnect click.
*/
export function LocalBoardConnect({
teamId,
teamName,
members,
connected,
port,
onClaimed,
onDisconnect,
}: LocalBoardConnectProps) {
const [paused, setPaused] = useState(false)
const [online, setOnline] = useState(true)
const [error, setError] = useState<string | null>(null)
const hasName = teamName.trim().length > 0
// Keep latest values without re-subscribing the poll loops.
const onClaimedRef = useRef(onClaimed)
onClaimedRef.current = onClaimed
const payloadRef = useRef({ teamName, members })
payloadRef.current = { teamName, members }
// Auto-detect + auto-bind while disconnected, not paused, and named.
useEffect(() => {
if (connected || paused || !hasName) return
let cancelled = false
const tick = async () => {
try {
const r = await autoClaimLocal({ teamId, ...payloadRef.current })
if (!cancelled && r) {
setError(null)
onClaimedRef.current(r)
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'could not connect')
}
}
void tick()
const id = window.setInterval(() => void tick(), 2000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [connected, paused, hasName, teamId])
// Heartbeat while connected → drives the "reconnecting" indicator + auto-recovery.
useEffect(() => {
if (!connected) return
let cancelled = false
const tick = () =>
getNodeStatus(teamId)
.then((s) => !cancelled && setOnline(s.online))
.catch(() => !cancelled && setOnline(false))
tick()
const id = window.setInterval(tick, 3000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [connected, teamId])
const disconnect = async () => {
setPaused(true)
await disconnectLocalBoard(teamId)
onDisconnect()
}
// ── connected ──
if (connected) {
const live = online
return (
<div
className={cn(
'rounded-md border px-4 py-3',
live ? 'border-[var(--green-border)] bg-[var(--green-bg)]' : 'border-[color-mix(in_srgb,var(--amber)_40%,transparent)] bg-[var(--surface-soft)]',
)}
data-testid="board-connected"
data-state={live ? 'online' : 'reconnecting'}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('h-2 w-2 shrink-0 rounded-full', live ? 'bg-[var(--green)] animate-pulse' : 'bg-[var(--amber)] animate-pulse')} />
<span className="text-sm font-medium">
{live ? 'Board connected' : 'Board unplugged — reconnecting…'}
</span>
</div>
<button
type="button"
onClick={disconnect}
className="shrink-0 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] hover:text-rose"
>
Disconnect
</button>
</div>
<div className="mt-1 font-mono text-[10px] text-[var(--muted)]">{port ?? 'usb · local'}</div>
</div>
)
}
// ── manually disconnected → wait for Reconnect ──
if (paused) {
return (
<div className="rounded-md border border-line bg-surface-soft px-4 py-3" data-testid="board-paused">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-ink-2">Board disconnected.</span>
<button
type="button"
onClick={() => setPaused(false)}
className="rounded-md border border-line-2 bg-surface px-3 py-1.5 text-[13px] text-ink transition-colors hover:border-blue hover:text-blue-ink"
>
Reconnect
</button>
</div>
</div>
)
}
// ── detecting (or waiting for a team name) ──
return (
<div className="rounded-md border border-line bg-surface-soft px-4 py-3" data-testid="board-detecting">
{hasName ? (
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-blue" />
<span className="text-sm text-ink-2">Detecting your board over USB…</span>
</div>
) : (
<span className="text-sm text-ink-3">Enter your team name above — your board connects automatically.</span>
)}
{error && <p className="mt-2 font-mono text-[11px] text-rose">{error}</p>}
<p className="mt-2 text-[12px] leading-relaxed text-ink-3">
Plugged in over USB and running the board app? It binds on its own — no code needed.
</p>
</div>
)
}
+19 -4
View File
@@ -1,7 +1,20 @@
import { Link } from 'react-router-dom'
import { useSession } from '@/store/session'
import { useLocalMode } from '@/lib/useLocalMode'
import { cn } from '@/lib/utils'
/**
* The browser-reachable board dashboard URL. In self-host/USB (local) mode the
* API stores the board's *container-facing* url (host.docker.internal:8080) so
* the API container can reach it — but the browser can't resolve that. From the
* browser the board's gateway is adb-forwarded to the local host on :8080, so
* open it at the current host on :8080.
*/
function boardHref(nodeUrl: string | null, localMode: boolean | null): string | null {
if (localMode) return `${window.location.protocol}//${window.location.hostname}:8080`
return nodeUrl
}
export interface OpenYourNodeProps {
/** 'hero' is the big primary CTA used on the setup page; 'inline' is a compact
* link for reuse inside later module pages. */
@@ -16,7 +29,9 @@ export interface OpenYourNodeProps {
*/
export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps) {
const device = useSession((s) => s.device)
const canOpen = device.connected && !!device.nodeUrl
const localMode = useLocalMode()
const nodeHref = boardHref(device.nodeUrl, localMode)
const canOpen = device.connected && !!nodeHref
if (!canOpen) {
return (
@@ -34,7 +49,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
if (variant === 'inline') {
return (
<a
href={device.nodeUrl!}
href={nodeHref!}
target="_blank"
rel="noopener noreferrer"
className={cn(
@@ -49,7 +64,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
return (
<a
href={device.nodeUrl!}
href={nodeHref!}
target="_blank"
rel="noopener noreferrer"
className={cn(
@@ -65,7 +80,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
{device.port ? `${device.port} · ` : ''}{device.nodeUrl}
{device.port ? `${device.port} · ` : ''}{nodeHref}
</div>
</div>
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
+13 -3
View File
@@ -24,7 +24,7 @@ describe('PhaseStrip', () => {
expect(phases).toHaveLength(5)
expect(phases.map((p) => p.textContent)).toEqual([
expect.stringContaining('Team reg'),
expect.stringContaining('Meet your node'),
expect.stringContaining('Meet your agent'),
expect.stringContaining('Module 1'),
expect.stringContaining('Module 2'),
expect.stringContaining('Module 3'),
@@ -42,15 +42,25 @@ describe('PhaseStrip', () => {
useSession.getState().completePhase('setup')
renderAt('/workshop/module1', 'm1')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('data-state', 'done')
expect(screen.getByText('Meet your node').closest('a')).toHaveAttribute('data-state', 'done')
expect(screen.getByText('Meet your agent').closest('a')).toHaveAttribute('data-state', 'done')
expect(screen.getByText('Module 1').closest('a')).toHaveAttribute('data-state', 'active')
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('data-state', 'pending')
})
it('never lights up a phase AFTER the active one, even with a stale stored flag', () => {
// a revisit / persisted session may have setup flagged done; on phase 1 it
// must still read pending, not green.
useSession.getState().completePhase('reg')
useSession.getState().completePhase('setup')
renderAt('/workshop', 'reg')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('data-state', 'active')
expect(screen.getByText('Meet your agent').closest('a')).toHaveAttribute('data-state', 'pending')
})
it('links each phase to its workshop sub-route', () => {
renderAt('/workshop')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('href', '/workshop')
expect(screen.getByText('Meet your node').closest('a')).toHaveAttribute('href', '/workshop/setup')
expect(screen.getByText('Meet your agent').closest('a')).toHaveAttribute('href', '/workshop/setup')
expect(screen.getByText('Module 1').closest('a')).toHaveAttribute('href', '/workshop/module1')
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('href', '/workshop/module2')
expect(screen.getByText('Module 3').closest('a')).toHaveAttribute('href', '/workshop/add')
+8 -2
View File
@@ -10,7 +10,7 @@ interface PhaseMeta {
const PHASES: PhaseMeta[] = [
{ key: 'reg', name: 'Team reg', to: '/workshop' },
{ key: 'setup', name: 'Meet your node', to: '/workshop/setup' },
{ key: 'setup', name: 'Meet your agent', to: '/workshop/setup' },
{ key: 'm1', name: 'Module 1', to: '/workshop/module1' },
{ key: 'm2', name: 'Module 2', to: '/workshop/module2' },
{ key: 'add', name: 'Module 3', to: '/workshop/add' },
@@ -22,10 +22,16 @@ export interface PhaseStripProps {
export function PhaseStrip({ active }: PhaseStripProps) {
const phases = useSession((s) => s.phases)
// Progress is driven by WHERE you are: phases before the active one are done,
// the active one is active, later ones are pending — regardless of stray
// stored flags (a completed-then-revisited phase must not light up a *future*
// phase green). Falls back to the stored flags only when no active phase is
// given (e.g. an embedded/preview use).
const activeIndex = PHASES.findIndex((p) => p.key === active)
return (
<nav aria-label="Workshop phases" data-testid="phase-strip" className="grid grid-cols-5 gap-2 px-8 py-4 border-b border-border">
{PHASES.map((p, i) => {
const done = phases[p.key]
const done = activeIndex >= 0 ? i < activeIndex : phases[p.key]
const isActive = active === p.key
return (
<NavLink
+94
View File
@@ -0,0 +1,94 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useSession } from '@/store/session'
import { sayHi, AGENT } from '@/lib/api'
import { cn } from '@/lib/utils'
type HiState = 'idle' | 'running' | 'ok'
/**
* "Say hi to your agent" — chats with the one workshop agent (AGENT) on the
* team's own board.
* A reply proves the node is live and listening. Shown once the board is bound.
*/
export function SayHiCard() {
const teamId = useSession((s) => s.teamId)
const connected = useSession((s) => s.device.connected)
const [hi, setHi] = useState<HiState>('idle')
const [reply, setReply] = useState('')
const [error, setError] = useState<string | null>(null)
const saidHi = async () => {
if (hi === 'running') return
setHi('running')
setError(null)
setReply('')
try {
const r = await sayHi(teamId, AGENT)
setReply(r || '(your node replied)')
setHi('ok')
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not reach your node.')
setHi('idle')
}
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Say hi to your agent</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Your board is a node running its own agent. Say hi — when it replies, your node is
live and listening.
</p>
{(hi !== 'idle' || reply) && (
<div className="space-y-2" data-testid="say-hi-chat">
<div className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[80%]">Hi 👋</span>
</div>
{hi === 'running' && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
your node is thinking…
</div>
)}
{reply && (
<div className="flex justify-start">
<span
className="rounded-lg border border-teal/40 bg-teal/5 px-3 py-1.5 text-sm max-w-[80%]"
data-testid="node-reply"
>
{reply}
</span>
</div>
)}
</div>
)}
<Button
variant={hi === 'ok' ? 'outline' : 'default'}
className="w-full justify-start"
disabled={!connected || hi === 'running'}
onClick={saidHi}
>
<span
className={cn(
'w-2 h-2 rounded-full mr-3',
hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)}
/>
{hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
</Button>
{error && (
<p role="alert" className="text-xs text-red-500 leading-relaxed">
{error}
</p>
)}
</CardContent>
</Card>
)
}
+12
View File
@@ -25,4 +25,16 @@ describe('SubmissionList', () => {
await user.click(screen.getByText('Bravo'))
expect(onSelect).toHaveBeenCalledWith('b')
})
it('shows the site tag when present (central-mode judge) and omits it otherwise', () => {
const { rerender } = render(
<SubmissionList
items={[{ teamId: 'site-a:t1', teamName: 'Alpha', submittedAt: 'x', scored: false, site: 'site-a' }]}
onSelect={() => {}}
/>,
)
expect(screen.getByTestId('submission-site')).toHaveTextContent('site-a')
rerender(<SubmissionList items={items} onSelect={() => {}} />)
expect(screen.queryByTestId('submission-site')).toBeNull()
})
})
+8
View File
@@ -31,6 +31,14 @@ export function SubmissionList({ items, selectedId, onSelect }: SubmissionListPr
<span className="text-sm font-medium truncate">{s.teamName || s.teamId}</span>
{s.scored && <Badge className="font-mono text-[8px] uppercase tracking-wider">scored</Badge>}
</div>
{s.site && (
<div
data-testid="submission-site"
className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground truncate mt-0.5"
>
{s.site}
</div>
)}
</button>
))}
</div>
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TelegramSetup } from './TelegramSetup'
import { useSession } from '@/store/session'
import { configureTelegram } from '@/lib/api'
vi.mock('@/lib/api', () => ({ configureTelegram: vi.fn() }))
const mockApply = vi.mocked(configureTelegram)
const TOKEN = '8842279117:AAFBBcbUNRsvhgzFvXE1W_Yh6VDCnGkyijw'
describe('TelegramSetup', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
mockApply.mockReset()
mockApply.mockResolvedValue(undefined)
})
it('walks through the wizard, applies the token to the node, and saves it', async () => {
const user = userEvent.setup()
render(<TelegramSetup />)
await user.click(screen.getByRole('button', { name: /set up telegram/i }))
// step 1 → step 2
await user.click(screen.getByRole('button', { name: /next/i }))
const finish = screen.getByRole('button', { name: /finish/i })
expect(finish).toBeDisabled() // no token yet
await user.type(screen.getByLabelText(/bot token/i), TOKEN)
expect(finish).toBeEnabled()
await user.click(finish)
// pushed to the running node before it's saved locally
expect(mockApply).toHaveBeenCalledWith(expect.any(String), TOKEN)
await waitFor(() => expect(useSession.getState().channels.telegram).toBe(TOKEN))
// card now shows the connected state with the bot id
expect(screen.getByTestId('telegram-configured')).toHaveTextContent(/bot 8842279117/)
})
it('surfaces an error and does NOT save when the node rejects the token', async () => {
mockApply.mockRejectedValue(new Error('could not apply the Telegram config — is your node online?'))
const user = userEvent.setup()
render(<TelegramSetup />)
await user.click(screen.getByRole('button', { name: /set up telegram/i }))
await user.click(screen.getByRole('button', { name: /next/i }))
await user.type(screen.getByLabelText(/bot token/i), TOKEN)
await user.click(screen.getByRole('button', { name: /finish/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/could not apply/i)
expect(useSession.getState().channels.telegram).toBeNull() // not saved on failure
})
it('rejects an obviously bad token and can be cancelled', async () => {
const user = userEvent.setup()
render(<TelegramSetup />)
await user.click(screen.getByRole('button', { name: /set up telegram/i }))
await user.click(screen.getByRole('button', { name: /next/i }))
const token = screen.getByLabelText(/bot token/i)
await user.type(token, 'not-a-token')
await user.tab() // blur → touched
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /finish/i })).toBeDisabled()
await user.click(screen.getByRole('button', { name: /back/i }))
await user.click(screen.getByRole('button', { name: /cancel/i }))
expect(useSession.getState().channels.telegram).toBeNull()
})
it('lets a configured token be removed', async () => {
useSession.getState().setChannels({ telegram: TOKEN })
const user = userEvent.setup()
render(<TelegramSetup />)
expect(screen.getByTestId('telegram-configured')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /remove/i }))
expect(useSession.getState().channels.telegram).toBeNull()
expect(screen.getByRole('button', { name: /set up telegram/i })).toBeInTheDocument()
})
})
+178
View File
@@ -0,0 +1,178 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Modal } from '@/components/ui/modal'
import { useSession } from '@/store/session'
import { configureTelegram } from '@/lib/api'
// A @BotFather token looks like `8842279117:AAF...` — a numeric id, a colon,
// then a ~35-char secret. Validate the shape so we don't save an obvious typo.
const TOKEN_RE = /^\d{6,}:[A-Za-z0-9_-]{30,}$/
const botId = (token: string) => token.split(':')[0]
/**
* Extra channel — Telegram. A guided modal wizard: make a bot with @BotFather,
* paste its token, done. The token is stored with the team so the node can pick
* it up (same value the node dashboard's Config → channels expects). Optional —
* the whole thing can be cancelled out of.
*/
export function TelegramSetup() {
const teamId = useSession((s) => s.teamId)
const telegram = useSession((s) => s.channels.telegram)
const setChannels = useSession((s) => s.setChannels)
const [open, setOpen] = useState(false)
const [step, setStep] = useState(0)
const [token, setToken] = useState('')
const [touched, setTouched] = useState(false)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
const valid = TOKEN_RE.test(token.trim())
const start = () => {
setToken(telegram ?? '')
setStep(0)
setTouched(false)
setError(null)
setApplying(false)
setOpen(true)
}
const finish = async () => {
if (applying) return
setApplying(true)
setError(null)
try {
// push the token to the running node + restart it so the channel starts
await configureTelegram(teamId, token.trim())
setChannels({ telegram: token.trim() })
setOpen(false)
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not apply the token to your node.')
} finally {
setApplying(false)
}
}
const remove = () => setChannels({ telegram: null })
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Extra channels
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">optional</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground leading-relaxed">
Your dashboard is the main way in, but you can also talk to your node from{' '}
<span className="font-medium text-foreground">Telegram</span>. Set up a bot and it routes
straight to your current agent.
</p>
{telegram ? (
<div
className="flex items-center justify-between gap-3 rounded-md border border-teal/40 bg-teal/5 px-3 py-2"
data-testid="telegram-configured"
>
<div className="flex items-center gap-2 min-w-0">
<span className="w-2 h-2 rounded-full bg-teal shrink-0" />
<span className="text-sm">
Telegram connected{' '}
<span className="font-mono text-xs text-muted-foreground">· bot {botId(telegram)}</span>
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={start} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground">
Edit
</button>
<button type="button" onClick={remove} className="font-mono text-[10px] uppercase tracking-widest text-red-500/80 hover:text-red-500">
Remove
</button>
</div>
</div>
) : (
<Button variant="outline" onClick={start}>
Set up Telegram →
</Button>
)}
</CardContent>
<Modal open={open} onClose={() => setOpen(false)} title="Connect Telegram">
{step === 0 ? (
<div className="space-y-4">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Step 1 of 2 · Make a bot</div>
<ol className="space-y-2 text-sm text-muted-foreground leading-relaxed list-decimal pl-5">
<li>
Open{' '}
<a href="https://t.me/BotFather" target="_blank" rel="noreferrer" className="text-primary hover:underline">
@BotFather
</a>{' '}
in Telegram.
</li>
<li>
Send <span className="font-mono text-xs">/newbot</span> and follow the prompts (pick a name + a
username ending in <span className="font-mono text-xs">bot</span>).
</li>
<li>BotFather replies with a token — copy it for the next step.</li>
</ol>
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => setStep(1)}>Next →</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Step 2 of 2 · Paste the token</div>
<div className="space-y-2">
<label htmlFor="tg-token" className="text-sm text-muted-foreground">
Bot token from @BotFather
</label>
<Input
id="tg-token"
value={token}
onChange={(e) => setToken(e.target.value)}
onBlur={() => setTouched(true)}
placeholder="8842279117:AAF…"
className="font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
{touched && token.trim() && !valid && (
<p role="alert" className="text-xs text-red-500">
That doesn&rsquo;t look like a bot token — it should be digits, a colon, then a long secret.
</p>
)}
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
On finish we write it to your node and restart it so Telegram comes up. You can
remove it any time.
</p>
{applying && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
writing config + restarting your node…
</div>
)}
{error && (
<p role="alert" className="text-xs text-red-500 leading-relaxed">
{error}
</p>
)}
</div>
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={() => setStep(0)} disabled={applying}>
← Back
</Button>
<Button onClick={finish} disabled={!valid || applying}>
{applying ? 'Applying…' : 'Finish'}
</Button>
</div>
</div>
)}
</Modal>
</Card>
)
}
+470
View File
@@ -0,0 +1,470 @@
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
export function TowerScene({ night, className }: { night: boolean; className?: string }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const nightRef = useRef<boolean>(night);
// keep the ref in sync with the prop so the animate loop can lerp toward it
useEffect(() => {
nightRef.current = night;
}, [night]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let disposed = false;
let raf = 0;
const W = () => container.clientWidth;
const H = () => container.clientHeight;
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(W(), H());
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xb7d2e8);
scene.fog = new THREE.FogExp2(0xb7d2e8, 0.0006);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
const camera = new THREE.PerspectiveCamera(45, W() / H(), 0.5, 3000);
camera.position.set(135, 78, 135);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.target.set(0, 42, 0);
controls.minDistance = 60;
controls.maxDistance = 1500;
controls.maxPolarAngle = Math.PI * 0.495;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
// ---- lights ----
const hemi = new THREE.HemisphereLight(0xcfe4f5, 0x36302a, 0.75);
scene.add(hemi);
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0xfff3e0, 3.3);
sun.position.set(150, 200, 90);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 600;
const sc = 200;
sun.shadow.camera.left = -sc;
sun.shadow.camera.right = sc;
sun.shadow.camera.top = sc;
sun.shadow.camera.bottom = -sc;
sun.shadow.bias = -0.0004;
scene.add(sun);
// ---- scene-wide collections (were `this.*`) ----
const winMats: THREE.MeshStandardMaterial[] = [];
const mainMats: THREE.Material[] = [];
const mainMeshes: THREE.Mesh[] = [];
const lampMats: THREE.MeshStandardMaterial[] = [];
const pointLights: THREE.PointLight[] = [];
const pools: THREE.Mesh[] = [];
const cars: { headMat: THREE.MeshStandardMaterial; tailMat: THREE.MeshStandardMaterial; poolMat: THREE.Material & { opacity: number } }[] = [];
// ---- helpers ----
const warm = { r: 255, g: 208, b: 132 };
const makeFacade = (cols: number, rows: number, litRatio: number) => {
const cell = 40, cw = cols * cell, ch = rows * cell;
const base = document.createElement('canvas'); base.width = cw; base.height = ch;
const bx = base.getContext('2d')!;
bx.fillStyle = '#191d24'; bx.fillRect(0, 0, cw, ch);
const lit = document.createElement('canvas'); lit.width = cw; lit.height = ch;
const lx = lit.getContext('2d')!; lx.fillStyle = '#000'; lx.fillRect(0, 0, cw, ch);
const mg = 5;
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const px = c * cell + mg, py = r * cell + mg, pw = cell - 2 * mg, ph = cell - 2 * mg;
bx.fillStyle = `rgb(${118 + Math.random() * 22 | 0},${148 + Math.random() * 22 | 0},${176 + Math.random() * 26 | 0})`;
bx.fillRect(px, py, pw, ph);
if (Math.random() < litRatio) {
const b = 0.55 + Math.random() * 0.45;
lx.fillStyle = `rgba(${warm.r},${warm.g},${warm.b},${b.toFixed(3)})`;
lx.fillRect(px, py, pw, ph);
}
}
const map = new THREE.CanvasTexture(base); map.colorSpace = THREE.SRGBColorSpace;
const emap = new THREE.CanvasTexture(lit); emap.colorSpace = THREE.SRGBColorSpace;
return { map, emap };
};
const roofMat = () => new THREE.MeshStandardMaterial({ color: 0x14171c, metalness: 0.7, roughness: 0.5, transparent: true, opacity: 1 });
const makeBox = (w: number, h: number, d: number, cols: number, rows: number, litRatio: number, isMain: boolean) => {
const { map, emap } = makeFacade(cols, rows, litRatio);
const win = new THREE.MeshStandardMaterial({
map, emissiveMap: emap, emissive: 0xffffff, emissiveIntensity: 0,
metalness: 0.15, roughness: 0.12, envMapIntensity: 1.0, transparent: true, opacity: 1
});
const rf = roofMat();
const geo = new THREE.BoxGeometry(w, h, d);
const mesh = new THREE.Mesh(geo, [win, win, rf, rf, win, win]);
mesh.castShadow = true; mesh.receiveShadow = true;
winMats.push(win);
if (isMain) { mainMats.push(win, rf); mainMeshes.push(mesh); }
return { mesh, win, rf };
};
// ---- main tower ----
const mainGroup = new THREE.Group();
const podium = makeBox(30, 8, 30, 12, 2, 0.55, true);
podium.mesh.position.y = 4; mainGroup.add(podium.mesh);
const tower = makeBox(22, 72, 22, 8, 18, 0.32, true);
tower.mesh.position.y = 44; mainGroup.add(tower.mesh);
const crown = new THREE.Mesh(new THREE.BoxGeometry(16, 6, 16),
new THREE.MeshStandardMaterial({ color: 0x1b1f26, metalness: 0.85, roughness: 0.35, transparent: true, opacity: 1 }));
crown.position.y = 83; crown.castShadow = true; mainGroup.add(crown);
mainMats.push(crown.material as THREE.Material); mainMeshes.push(crown);
const mech = new THREE.Mesh(new THREE.BoxGeometry(9, 4, 9),
new THREE.MeshStandardMaterial({ color: 0x0f1216, metalness: 0.8, roughness: 0.6, transparent: true, opacity: 1 }));
mech.position.y = 88; mech.castShadow = true; mainGroup.add(mech);
mainMats.push(mech.material as THREE.Material); mainMeshes.push(mech);
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.5, 16, 12),
new THREE.MeshStandardMaterial({ color: 0x2a2f37, metalness: 0.9, roughness: 0.4, transparent: true, opacity: 1 }));
antenna.position.y = 98; mainGroup.add(antenna);
mainMats.push(antenna.material as THREE.Material); mainMeshes.push(antenna);
const beaconMat = new THREE.MeshStandardMaterial({ color: 0x330000, emissive: 0xff2a1a, emissiveIntensity: 0 });
const beacon = new THREE.Mesh(new THREE.SphereGeometry(0.7, 12, 12), beaconMat);
beacon.position.y = 106.5; mainGroup.add(beacon);
scene.add(mainGroup);
// wireframe overlay of main building
const wireMat = new THREE.LineBasicMaterial({ color: 0x7fe0ff, transparent: true, opacity: 0 });
const wireGroup = new THREE.Group();
mainMeshes.forEach(mesh => {
const wf = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 1), wireMat);
wf.position.copy(mesh.position); wf.rotation.copy(mesh.rotation);
wireGroup.add(wf);
});
wireGroup.visible = false;
scene.add(wireGroup);
// ---- wireframe interior: per-floor plans ----
const floorMat = new THREE.LineBasicMaterial({ color: 0x3dffa0, transparent: true, opacity: 0 });
const gridMat = new THREE.LineBasicMaterial({ color: 0x27c47e, transparent: true, opacity: 0 });
const interiorGroup = new THREE.Group();
const wallPos: number[] = [], gridPos: number[] = [];
const seg3 = (arr: number[], x1: number, y1: number, z1: number, x2: number, y2: number, z2: number) => arr.push(x1, y1, z1, x2, y2, z2);
const extrude = (segs: number[][], y0: number, h: number) => {
for (const [a, b, c, d] of segs) {
seg3(wallPos, a, y0, b, c, y0, d);
seg3(wallPos, a, y0 + h, b, c, y0 + h, d);
seg3(wallPos, a, y0, b, a, y0 + h, b);
seg3(wallPos, c, y0, d, c, y0 + h, d);
}
};
const gridPlate = (s: number, y0: number) => {
const step = 3, yy = y0 + 0.03;
for (let x = -s + step; x < s; x += step) seg3(gridPos, x, yy, -s, x, yy, s);
for (let z = -s + step; z < s; z += step) seg3(gridPos, -s, yy, z, s, yy, z);
};
const bath = (segs: number[][], cx: number, cz: number) => {
const b = 0.9;
segs.push([cx - b, cz - b, cx + b, cz - b], [cx + b, cz - b, cx + b, cz + b],
[cx + b, cz + b, cx - b, cz + b], [cx - b, cz + b, cx - b, cz - b], [cx, cz - b, cx, cz + b]);
};
const makePlan = (s: number, type: string) => {
const segs: number[][] = [], R = s - 0.7;
segs.push([-R, -R, R, -R], [R, -R, R, R], [R, R, -R, R], [-R, R, -R, -R]);
if (type === 'mech') {
const n = 6;
for (let i = 1; i < n; i++) { const p = -R + 2 * R * i / n; segs.push([p, -R, p, R], [-R, p, R, p]); }
return segs;
}
const cfg = ({ lobby: { c: false, rooms: 1, bath: 0 }, open: { c: true, rooms: 2, bath: 0 },
office: { c: true, rooms: 6, bath: 2 }, hotel: { c: true, rooms: 8, bath: 99 },
pent: { c: false, rooms: 3, bath: 1 } } as Record<string, { c: boolean; rooms: number; bath: number }>)[type];
const rc = (k: number) => -R + 2 * R * (k + 0.5) / cfg.rooms;
if (cfg.c) {
const ch = 2.0;
segs.push([-R, -ch, R, -ch], [-R, ch, R, ch]);
for (let k = 1; k < cfg.rooms; k++) { const x = -R + 2 * R * k / cfg.rooms; segs.push([x, -R, x, -ch], [x, ch, x, R]); }
for (let k = 0; k < cfg.rooms; k++) {
const useBath = cfg.bath === 99 ? true : (cfg.bath === 2 ? (k === 0 || k === cfg.rooms - 1) : false);
if (useBath) bath(segs, rc(k), R - 1.4);
}
} else {
for (let k = 1; k < cfg.rooms; k++) { const x = -R + 2 * R * k / cfg.rooms; segs.push([x, -R, x, R]); }
if (cfg.bath) bath(segs, R - 2, R - 2);
}
return segs;
};
const pattern = ['lobby', 'lobby', 'open', 'office', 'office', 'office', 'office', 'hotel', 'office', 'office',
'mech', 'office', 'office', 'office', 'office', 'hotel', 'office', 'open', 'pent', 'pent'];
for (let i = 0; i < 20; i++) {
const y0 = i * 4, s = (y0 < 8 ? 15 : 11) - 0.5;
extrude(makePlan(s, pattern[i]), y0, 4);
gridPlate(s, y0);
}
const wgeo = new THREE.BufferGeometry();
wgeo.setAttribute('position', new THREE.Float32BufferAttribute(wallPos, 3));
interiorGroup.add(new THREE.LineSegments(wgeo, floorMat));
const ggeo = new THREE.BufferGeometry();
ggeo.setAttribute('position', new THREE.Float32BufferAttribute(gridPos, 3));
interiorGroup.add(new THREE.LineSegments(ggeo, gridMat));
interiorGroup.visible = false;
scene.add(interiorGroup);
// ---- procedural terrain: Turin / Po valley ----
const fract = (v: number) => v - Math.floor(v);
const hash = (x: number, z: number) => fract(Math.sin(x * 127.1 + z * 311.7) * 43758.5453);
const vnoise = (x: number, z: number) => {
const xi = Math.floor(x), zi = Math.floor(z), xf = x - xi, zf = z - zi;
const u = xf * xf * (3 - 2 * xf), v = zf * zf * (3 - 2 * zf);
const a = hash(xi, zi), b = hash(xi + 1, zi), c = hash(xi, zi + 1), d = hash(xi + 1, zi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
};
const fbm = (x: number, z: number) => { let f = 0, amp = 0.5, fr = 1; for (let i = 0; i < 5; i++) { f += amp * vnoise(x * fr, z * fr); fr *= 2; amp *= 0.5; } return f; };
const smooth = (a: number, b: number, x: number) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
// height field (planar px,py -> world x=px, z=-py)
const HT = (px: number, py: number) => {
let h = (fbm(px * 0.0016 + 10, py * 0.0016) - 0.5) * 46;
const east = Math.max(0, (px - 300) / 1500);
h += east * east * 150 * fbm(px * 0.004, py * 0.004);
const north = Math.max(0, (py - 820) / 1200);
const ridge = 1 - Math.abs(fbm(px * 0.0022 + 5, py * 0.0022) * 2 - 1);
h += Math.pow(north, 1.5) * 600 * (0.35 + 0.65 * ridge);
h *= smooth(120, 300, Math.hypot(px, py));
return h;
};
const tGeo = new THREE.PlaneGeometry(4600, 4600, 260, 260);
const tp = tGeo.attributes.position, cols: number[] = [];
const cGrass = new THREE.Color(0x5c8a39), cDry = new THREE.Color(0x83904c),
cForest = new THREE.Color(0x3a5c27), cRock = new THREE.Color(0x6f665a), cSnow = new THREE.Color(0xeef2f6);
const tc = new THREE.Color();
for (let i = 0; i < tp.count; i++) {
const x = tp.getX(i), y = tp.getY(i), h = HT(x, y);
tp.setZ(i, h);
const nz = fbm(x * 0.02, y * 0.02);
if (h < 10) tc.copy(cGrass).lerp(cDry, nz * 0.5);
else if (h < 70) tc.copy(cGrass).lerp(cForest, smooth(10, 70, h));
else if (h < 300) tc.copy(cForest).lerp(cRock, smooth(70, 300, h));
else tc.copy(cRock).lerp(cSnow, smooth(300, 430, h));
tc.offsetHSL(0, 0, (nz - 0.5) * 0.06);
cols.push(tc.r, tc.g, tc.b);
}
tGeo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
tGeo.rotateX(-Math.PI / 2); tGeo.computeVertexNormals();
const terrain = new THREE.Mesh(tGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1, metalness: 0 }));
terrain.receiveShadow = true; scene.add(terrain);
// ---- Po river ----
const rpts: THREE.Vector3[] = [];
for (let k = 0; k <= 24; k++) { const py = -700 + k * 60, px = -260 + 230 * Math.sin(py * 0.004); rpts.push(new THREE.Vector3(px, 2, -py)); }
const rGeo = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(rpts), 240, 27, 10, false);
const water = new THREE.Mesh(rGeo, new THREE.MeshStandardMaterial({ color: 0x2f6d97, roughness: 0.12, metalness: 0.35 }));
water.scale.y = 0.04; water.position.y = 1.3; scene.add(water);
// ---- surrounding town (Turin low blocks) ----
const dummy = new THREE.Object3D();
const townPal = [0xb08159, 0xc39a6b, 0x9a6b4a, 0xc7b393, 0xa8875f, 0x8f5f43];
const NB = 220, townI = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 }), NB);
let bi = 0, ba = 0;
while (bi < NB && ba < NB * 8) {
ba++;
const ang = Math.random() * 7, r = 150 + Math.random() * 640, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
const h = HT(px, py); if (Math.hypot(px, py) < 140 || h > 45) continue;
const bw = 9 + Math.random() * 16, bh = 8 + Math.random() * 28, bd = 9 + Math.random() * 16;
dummy.position.set(px, h + bh / 2, -py); dummy.scale.set(bw, bh, bd); dummy.rotation.set(0, Math.random() * 7, 0);
dummy.updateMatrix(); townI.setMatrixAt(bi, dummy.matrix);
townI.setColorAt(bi, tc.setHex(townPal[bi % townPal.length]));
bi++;
}
townI.count = bi; townI.instanceMatrix.needsUpdate = true; if (townI.instanceColor) townI.instanceColor.needsUpdate = true;
scene.add(townI);
// ---- trees on hills ----
const NT = 560;
const trunkI = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.6, 0.9, 6, 5),
new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 1 }), NT);
const foliI = new THREE.InstancedMesh(new THREE.ConeGeometry(3.2, 9, 7),
new THREE.MeshStandardMaterial({ color: 0x2f5223, roughness: 1 }), NT);
let fi = 0, fa = 0;
while (fi < NT && fa < NT * 8) {
fa++;
const ang = Math.random() * 7, r = 200 + Math.random() * 1050, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
const h = HT(px, py); if (Math.hypot(px, py) < 170 || h < 6 || h > 260) continue;
const s = 0.7 + Math.random() * 1.1, ry = Math.random() * 7;
dummy.rotation.set(0, ry, 0);
dummy.position.set(px, h + 3 * s, -py); dummy.scale.set(s, s, s); dummy.updateMatrix(); trunkI.setMatrixAt(fi, dummy.matrix);
dummy.position.set(px, h + (6 + 4.5) * s, -py); dummy.scale.set(s, s, s); dummy.updateMatrix(); foliI.setMatrixAt(fi, dummy.matrix);
fi++;
}
trunkI.count = foliI.count = fi;
trunkI.instanceMatrix.needsUpdate = true; foliI.instanceMatrix.needsUpdate = true;
scene.add(trunkI); scene.add(foliI);
// ---- grass tufts near base ----
const gcv = document.createElement('canvas'); gcv.width = gcv.height = 64;
const gx = gcv.getContext('2d')!;
for (let i = 0; i < 26; i++) {
const bx = 8 + Math.random() * 48;
gx.strokeStyle = `rgb(${70 + Math.random() * 40 | 0},${130 + Math.random() * 50 | 0},${50 + Math.random() * 30 | 0})`;
gx.lineWidth = 1.5 + Math.random() * 1.5; gx.beginPath(); gx.moveTo(bx, 64);
gx.quadraticCurveTo(bx + (Math.random() - 0.5) * 20, 34, bx + (Math.random() - 0.5) * 26, 6 + Math.random() * 10); gx.stroke();
}
const gTex = new THREE.CanvasTexture(gcv); gTex.colorSpace = THREE.SRGBColorSpace;
const NG = 2400;
const tuftI = new THREE.InstancedMesh(new THREE.PlaneGeometry(5, 5),
new THREE.MeshStandardMaterial({ map: gTex, alphaTest: 0.5, side: THREE.DoubleSide, roughness: 1, color: 0x7aa34e }), NG);
let gi = 0, ga = 0;
while (gi < NG && ga < NG * 6) {
ga++;
const ang = Math.random() * 7, r = 120 + Math.random() * 460, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
const h = HT(px, py); if (h > 26) continue;
dummy.position.set(px, h + 2.4, -py); dummy.scale.set(1, 1, 1); dummy.rotation.set(0, Math.random() * 7, 0);
dummy.updateMatrix(); tuftI.setMatrixAt(gi, dummy.matrix); gi++;
}
tuftI.count = gi; tuftI.instanceMatrix.needsUpdate = true; scene.add(tuftI);
const envGround: THREE.MeshStandardMaterial[] = [
terrain.material as THREE.MeshStandardMaterial,
townI.material as THREE.MeshStandardMaterial,
trunkI.material as THREE.MeshStandardMaterial,
foliI.material as THREE.MeshStandardMaterial,
tuftI.material as THREE.MeshStandardMaterial,
water.material as THREE.MeshStandardMaterial,
];
// ---- image sky domes (day / night) ----
const loader = new THREE.TextureLoader();
const skyDome = (url: string) => {
const tex = loader.load(url);
tex.colorSpace = THREE.SRGBColorSpace;
const geo = new THREE.SphereGeometry(2600, 48, 32);
const mat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, transparent: true, opacity: 1, depthWrite: false, fog: false });
const m = new THREE.Mesh(geo, mat);
scene.add(m); return m;
};
const skyDay = skyDome('/skyscraper/sky-day.png');
const skyNight = skyDome('/skyscraper/sky-night.png');
(skyNight.material as THREE.MeshBasicMaterial).opacity = 0;
// subtle cool moonlight fill at night
const moonLight = new THREE.DirectionalLight(0x9fb8e6, 0);
moonLight.position.set(-520, 430, -720);
scene.add(moonLight);
// ---- composer / bloom ----
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(W(), H()), 0.18, 0.7, 0.9);
composer.addPass(bloom);
// ---- resize ----
const ro = new ResizeObserver(() => {
const w = W(), h = H(); if (!w || !h) return;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h); composer.setSize(w, h);
});
ro.observe(container);
// ---- animation ----
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const cDay = new THREE.Color(0xb7d2e8), cNight = new THREE.Color(0x05070d);
const fDay = new THREE.Color(0xc6dcec), fNight = new THREE.Color(0x080b14);
const clock = new THREE.Clock();
const skyDayMat = skyDay.material as THREE.MeshBasicMaterial;
const skyNightMat = skyNight.material as THREE.MeshBasicMaterial;
const applyNight = (n: number) => {
(scene.background as THREE.Color).copy(cDay).lerp(cNight, n);
const fog = scene.fog as THREE.FogExp2;
fog.color.copy(fDay).lerp(fNight, n);
fog.density = lerp(0.0006, 0.0017, n);
hemi.intensity = lerp(0.75, 0.05, n);
ambient.intensity = lerp(0.25, 0.03, n);
sun.intensity = lerp(3.3, 0.0, n);
renderer.toneMappingExposure = lerp(1.0, 1.12, n);
const wi = lerp(0.0, 1.55, n), env = lerp(1.0, 0.28, n);
winMats.forEach(m => { m.emissiveIntensity = wi; m.envMapIntensity = env; });
lampMats.forEach(m => m.emissiveIntensity = lerp(0, 2.4, n));
pointLights.forEach(l => l.intensity = lerp(0, 900, n));
pools.forEach(p => (p.material as THREE.Material & { opacity: number }).opacity = lerp(0, 0.55, n));
cars.forEach(c => {
c.headMat.emissiveIntensity = lerp(0, 3, n);
c.tailMat.emissiveIntensity = lerp(0, 2.2, n);
c.poolMat.opacity = lerp(0, 0.5, n);
});
bloom.strength = lerp(0.18, 1.0, n);
bloom.threshold = lerp(0.9, 0.0, n);
skyDayMat.opacity = 1 - n;
skyNightMat.opacity = n;
moonLight.intensity = n * 0.7;
const genv = lerp(1.0, 0.12, n);
envGround.forEach(m => { m.envMapIntensity = genv; });
};
const applyWire = (w: number) => {
const solid = 1 - w;
mainMats.forEach(m => { (m as THREE.Material & { opacity: number }).opacity = solid; m.transparent = true; });
mainMeshes.forEach(mesh => { mesh.visible = w < 0.995; });
wireGroup.visible = w > 0.005;
wireMat.opacity = w;
interiorGroup.visible = w > 0.005;
floorMat.opacity = w * 1.0;
gridMat.opacity = w * 0.72;
};
// day/night lerp state (init to correct mode so it starts right, then slides on prop change)
let nl = night ? 1 : 0;
let wf = 0; // mode = 'solid' -> wireframe stays 0
// control state (was this._ctrl); autoRotate ON, mode solid
const ctrl = { mode: 'solid' as const, autoRotate: true };
const animate = () => {
raf = requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
const k = Math.min(1, dt * 4);
nl += ((nightRef.current ? 1 : 0) - nl) * k;
wf += (((ctrl.mode as string) === 'wire' ? 1 : 0) - wf) * k;
applyNight(nl);
applyWire(wf);
beaconMat.emissiveIntensity = nl * (0.4 + 0.6 * Math.abs(Math.sin(t * 2.2)));
controls.autoRotate = ctrl.autoRotate;
controls.update();
composer.render();
};
applyNight(nl); applyWire(wf);
if (!disposed) animate();
return () => {
disposed = true;
cancelAnimationFrame(raf);
ro.disconnect();
controls.dispose();
renderer.dispose();
pmrem.dispose();
if (renderer.domElement.parentNode === container) {
container.removeChild(renderer.domElement);
}
};
}, []);
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />;
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { VoiceSetup } from './VoiceSetup'
import { useSession } from '@/store/session'
describe('VoiceSetup', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('toggles the voice preference on and off', async () => {
const user = userEvent.setup()
render(<VoiceSetup />)
const toggle = screen.getByRole('switch', { name: /enable voice/i })
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(useSession.getState().channels.voice).toBe(true)
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(toggle)
expect(useSession.getState().channels.voice).toBe(false)
})
})
+48
View File
@@ -0,0 +1,48 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useSession } from '@/store/session'
import { cn } from '@/lib/utils'
/**
* Voice channel — a simple enable/disable toggle. When on, the team uses the mic
* button in the node dashboard to talk to the agent directly. Client-side pref
* (the node dashboard owns the actual mic), so this just records the choice.
*/
export function VoiceSetup() {
const voice = useSession((s) => s.channels.voice)
const setChannels = useSession((s) => s.setChannels)
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Voice
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">optional</span>
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Talk to your node out loud — use the mic button in its dashboard. Enable it here so your
team knows it&rsquo;s on.
</p>
<button
type="button"
role="switch"
aria-checked={voice}
aria-label="Enable voice"
onClick={() => setChannels({ voice: !voice })}
className={cn(
'relative w-11 h-6 rounded-full shrink-0 transition-colors',
voice ? 'bg-teal' : 'bg-muted-foreground/30',
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-background shadow transition-transform',
voice && 'translate-x-5',
)}
/>
</button>
</CardContent>
</Card>
)
}
@@ -0,0 +1,313 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { CONSTITUTION, type ConstitutionPiece } from '@/lib/agentConstitution'
import { getPersonality, savePersonality, refinePersonality } from '@/lib/api'
import { useSession } from '@/store/session'
import { cn } from '@/lib/utils'
/**
* The agent's makeup, as a vertical strip of icons beside Clawd on the LED matrix.
* Each icon is one of the "constitution" files the on-board agent loads into its
* system prompt. Clicking one slides a card OUT FROM BEHIND the dashboard — same
* size as it — showing the file's name, its short description, and its ACTUAL
* contents (the real values shipped on the board).
*
* The strip lives inside the rail; the slide-out panel is a sibling of the rail
* (in CockpitLayout) so it can emerge from behind it. State is shared via context.
*/
interface ArchState {
selected: ConstitutionPiece | null
open: (p: ConstitutionPiece) => void
close: () => void
}
const Ctx = createContext<ArchState | null>(null)
export function ArchitectureProvider({ children }: { children: React.ReactNode }) {
const [selected, setSelected] = useState<ConstitutionPiece | null>(null)
return (
<Ctx.Provider value={{ selected, open: setSelected, close: () => setSelected(null) }}>{children}</Ctx.Provider>
)
}
function useArchitecture(): ArchState {
const c = useContext(Ctx)
if (!c) throw new Error('useArchitecture must be used within <ArchitectureProvider>')
return c
}
/** The vertical icon strip (rendered beside Clawd inside the rail). */
export function AgentArchitectureStrip({ className }: { className?: string }) {
const { selected, open, close } = useArchitecture()
return (
<div className={cn('flex flex-col gap-1.5', className)}>
{CONSTITUTION.map((p) => {
const active = selected?.key === p.key
return (
<button
key={p.key}
type="button"
onClick={() => (active ? close() : open(p))}
title={`${p.file} — ${p.short}`}
aria-label={`${p.title}: ${p.short}`}
aria-pressed={active}
className={cn(
'grid h-8 w-8 place-items-center rounded-[8px] border text-[15px] transition-colors',
active ? 'border-blue bg-blue/10' : 'border-line bg-surface hover:border-blue',
)}
>
{p.icon}
</button>
)
})}
</div>
)
}
/**
* The slide-out card. Shows a makeup file's live contents (pulled from the board,
* falling back to the baked default), and lets the participant EDIT it (🔧) or
* REFINE it from a plain-language intent (🪄 — the agent rewrites it to clean,
* safe Markdown). Saving writes the file to the board and restarts the agent, so
* re-opening the card shows their changes.
*/
export function ArchitecturePanel() {
const { selected, close } = useArchitecture()
const teamId = useSession((s) => s.teamId)
const connected = useSession((s) => s.device.connected)
const [view, setView] = useState<ConstitutionPiece | null>(null)
const [live, setLive] = useState<string | null>(null) // board content, once loaded
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
const [wandOpen, setWandOpen] = useState(false)
const [intent, setIntent] = useState('')
const [busy, setBusy] = useState<'idle' | 'saving' | 'refining'>('idle')
const [note, setNote] = useState<string | null>(null)
// keep the last piece rendered through the close animation
useEffect(() => {
if (selected) setView(selected)
}, [selected])
// On open: reset edit state and pull the current file from the board.
useEffect(() => {
if (!selected) return
setEditing(false)
setWandOpen(false)
setIntent('')
setNote(null)
setLive(null)
if (connected) {
getPersonality(teamId, selected.file).then((r) => {
if (r && r.content.trim()) setLive(r.content)
})
}
}, [selected, teamId, connected])
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && close()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [close])
const openState = selected != null
const piece = view
const content = live ?? piece?.content ?? ''
const startEdit = () => {
setDraft(content)
setEditing(true)
setWandOpen(false)
setNote(null)
}
const toggleWand = () => {
if (!editing) setDraft(content)
setEditing(true)
setWandOpen((o) => !o)
setNote(null)
}
const refine = async () => {
if (!piece || !intent.trim()) return
setBusy('refining')
setNote(null)
try {
const md = await refinePersonality(teamId, piece.file, piece.short, intent.trim())
if (md) {
setDraft(md)
setWandOpen(false)
setIntent('')
} else setNote('The agent returned nothing — try rephrasing.')
} catch {
setNote('Refine failed — is the board online?')
} finally {
setBusy('idle')
}
}
const save = async () => {
if (!piece) return
setBusy('saving')
setNote(null)
try {
await savePersonality(teamId, piece.file, draft)
setLive(draft)
setEditing(false)
setWandOpen(false)
setNote('Saved — agent restarted with your changes.')
} catch {
setNote('Save failed — is the board online?')
} finally {
setBusy('idle')
}
}
return (
<div
aria-hidden={!openState}
className={cn(
'flex flex-col overflow-hidden rounded-[18px] border border-line bg-surface-soft text-ink shadow-[0_24px_60px_-20px_rgba(0,0,0,0.5)] transition-[transform,opacity] duration-300 ease-out',
// below lg there's no room to slide beside the dashboard, so it's a fixed
// overlay that fades in; at lg+ it slides out to the right of the dashboard.
'max-lg:fixed max-lg:inset-x-3 max-lg:inset-y-4 max-lg:z-50 lg:absolute lg:inset-0',
openState
? 'pointer-events-auto translate-x-0 opacity-100 lg:translate-x-[calc(100%_+_10px)]'
: 'pointer-events-none translate-x-0 max-lg:opacity-0',
)}
>
{piece && (
<>
{/* header */}
<div className="flex items-start gap-3 border-b border-line px-5 py-4">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[11px] border border-line bg-surface text-[20px]">
{piece.icon}
</span>
<div className="min-w-0">
<div className="font-mono text-[10.5px] tracking-[0.1em] text-blue">{piece.file}</div>
<div className="mt-0.5 text-[18px] font-semibold leading-tight text-ink">
{piece.title} <span className="text-ink-3">· {piece.short}</span>
</div>
</div>
<div className="ml-auto flex items-center gap-1">
{connected && (
<>
<button
type="button"
onClick={startEdit}
aria-label="Edit this file"
aria-pressed={editing && !wandOpen}
title="Edit"
className={cn(
'grid h-8 w-8 place-items-center rounded-[8px] border text-[14px] transition-colors',
editing && !wandOpen ? 'border-blue bg-blue/10' : 'border-line hover:border-blue',
)}
>
🔧
</button>
<button
type="button"
onClick={toggleWand}
aria-label="Refine with AI"
aria-pressed={wandOpen}
title="Refine with AI"
className={cn(
'grid h-8 w-8 place-items-center rounded-[8px] border text-[14px] transition-colors',
wandOpen ? 'border-blue bg-blue/10' : 'border-line hover:border-blue',
)}
>
🪄
</button>
</>
)}
<button
type="button"
onClick={close}
aria-label="Close"
className="ml-0.5 rounded-md px-2 py-1 text-[20px] leading-none text-ink-3 transition-colors hover:text-ink"
>
×
</button>
</div>
</div>
{/* wand: describe-what-you-want → agent refines to clean Markdown */}
{wandOpen && (
<div className="border-b border-line bg-surface px-5 py-3">
<div className="mb-1.5 font-mono text-[9px] tracking-[0.14em] text-ink-3">DESCRIBE WHAT YOU WANT</div>
<textarea
value={intent}
onChange={(e) => setIntent(e.target.value)}
placeholder={`A sentence or two on what this ${piece.title.toLowerCase()} should be…`}
className="h-16 w-full resize-none rounded-[9px] border border-line bg-surface-soft px-3 py-2 text-[13px] leading-[1.5] text-ink focus:border-blue focus:outline-none"
/>
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={refine}
disabled={busy !== 'idle' || !intent.trim()}
className="rounded-[9px] bg-blue px-3.5 py-1.5 text-[13px] font-medium text-white transition-[opacity] hover:bg-blue-ink disabled:opacity-45"
>
{busy === 'refining' ? 'Refining…' : 'Refine ✨'}
</button>
</div>
</div>
)}
{/* body: live contents, editable when 🔧/🪄 is on */}
<div className="flex min-h-0 flex-1 flex-col px-5 py-4">
<div className="mb-2 font-mono text-[9px] tracking-[0.14em] text-ink-3">
{editing ? 'EDITING · MARKDOWN' : 'FILE CONTENTS'}
</div>
{editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
className="min-h-0 flex-1 w-full resize-none rounded-[10px] border border-line bg-surface px-3 py-2.5 font-mono text-[12px] leading-[1.6] text-ink focus:border-blue focus:outline-none"
/>
) : (
<pre className="min-h-0 flex-1 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.6] text-ink-2">
{content}
</pre>
)}
</div>
{/* footer: save controls (editing) or the file path */}
{editing ? (
<div className="flex items-center gap-3 border-t border-line px-5 py-3">
{note && <span className="min-w-0 flex-1 truncate text-[12px] text-ink-3">{note}</span>}
{!note && <span className="flex-1" />}
<button
type="button"
onClick={() => {
setEditing(false)
setWandOpen(false)
}}
className="rounded-[9px] border border-line px-3.5 py-1.5 text-[13px] text-ink-2 transition-colors hover:border-blue"
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={busy !== 'idle'}
className="rounded-[9px] bg-blue px-4 py-1.5 text-[13px] font-medium text-white transition-[opacity] hover:bg-blue-ink disabled:opacity-45"
>
{busy === 'saving' ? 'Saving…' : 'Save'}
</button>
</div>
) : (
<div className="border-t border-line px-5 py-2.5 font-mono text-[10px] leading-[1.5] tracking-[0.02em] text-ink-3">
{note ?? (
<>
{connected ? 'live from ' : 'default (connect a board to edit) · '}
~/.zeroclaw/agents/default/workspace/{piece.file}
</>
)}
</div>
)}
</>
)}
</div>
)
}
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from 'react'
import type { ChatMessage, StarterState } from '@/lib/useAgentChat'
import { cn } from '@/lib/utils'
/**
* Presentational chat pieces shared by the rail (Meet your agent) and the Module 1
* configurator, so both render the SAME card driven by the shared conversation.
* Theme-aware (ink/line/surface tokens): light in light mode, dark in dark mode.
*/
/** The three canned prompts — imperative, so the on-board model reliably runs tools. */
export const STARTERS: { id: string; label: string; text: string }[] = [
{ id: 'i2c', label: 'List I2C devices', text: 'List the I2C devices on the bus' },
{ id: 'count', label: 'Count on the matrix', text: 'Count to 100 and print the value once a second in the LED matrix' },
{ id: 'scroll', label: 'Scroll GO CLAWS', text: 'Scroll GO CLAWS on the LED matrix' },
]
export function AgentChatPane({
messages,
sending,
online,
onSend,
heightClass = 'h-[230px]',
label = 'CHAT · DEFAULT AGENT',
}: {
messages: ChatMessage[]
sending: boolean
online: boolean
onSend: (text: string) => void
heightClass?: string
label?: string
}) {
const [draft, setDraft] = useState('')
const scroller = useRef<HTMLDivElement>(null)
useEffect(() => {
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
}, [messages])
const submit = () => {
const t = draft.trim()
if (!t || sending) return
onSend(t)
setDraft('')
}
return (
<div>
{label && <div className="font-mono text-[9.5px] tracking-[0.18em] text-ink-3">{label}</div>}
<div className={cn(label && 'mt-2', 'rounded-[10px] border border-line bg-surface')}>
<div
ref={scroller}
data-testid="rail-chat-transcript"
className={cn('space-y-2 overflow-y-auto px-[13px] py-3 text-[12px] leading-[1.5]', heightClass)}
>
{messages.length === 0 ? (
<div className="font-mono text-[11px] text-ink-3">
{online ? 'say something to your agent — it runs on the board' : 'connect your board to chat'}
</div>
) : (
messages.map((m, i) =>
m.who === 'you' ? (
<div key={i} className="flex justify-end">
<span className="max-w-[85%] rounded-[9px] bg-blue/10 px-2.5 py-1.5 text-ink">{m.text}</span>
</div>
) : (
<div key={i} className="flex justify-start">
<span
className={cn(
'max-w-[88%] rounded-[9px] border border-line bg-surface-soft px-2.5 py-1.5',
m.kind === 'error' ? 'text-destructive' : 'text-ink-2',
)}
>
{m.text}
</span>
</div>
),
)
)}
</div>
<form
className="flex items-center gap-2 border-t border-line px-2.5 py-2"
onSubmit={(e) => {
e.preventDefault()
submit()
}}
>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={!online}
data-testid="rail-chat-input"
placeholder={online ? 'Message your agent…' : 'board offline'}
className="min-w-0 flex-1 bg-transparent font-mono text-[11.5px] text-ink placeholder:text-faint focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={!online || sending || !draft.trim()}
className="shrink-0 rounded-[7px] border border-line bg-surface-soft px-2.5 py-1 font-mono text-[10px] tracking-[0.1em] text-blue transition-colors hover:border-blue disabled:opacity-40"
>
{sending ? '…' : 'SEND'}
</button>
</form>
</div>
</div>
)
}
export function StarterRow({
starters,
sending,
online,
onSend,
}: {
starters: Record<string, StarterState>
sending: boolean
online: boolean
onSend: (text: string, starterId?: string) => void
}) {
return (
<div className="flex flex-col gap-1.5">
{STARTERS.map((s) => {
const st = starters[s.id] ?? 'idle'
return (
<button
key={s.id}
type="button"
data-testid={`starter-${s.id}`}
disabled={!online || (sending && st !== 'running')}
onClick={() => onSend(s.text, s.id)}
className={cn(
'flex items-center gap-2.5 rounded-[8px] border px-3 py-1.5 text-left font-mono text-[11px] transition-colors disabled:opacity-40',
st === 'done'
? 'border-green/50 bg-green/10 text-green'
: st === 'running'
? 'border-amber/50 text-amber'
: 'border-line bg-surface text-ink-2 hover:border-blue',
)}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
st === 'done' ? 'bg-green' : st === 'running' ? 'bg-amber animate-pulse' : 'bg-ink-3',
)}
/>
<span className="flex-1">{s.label}</span>
<span className="text-[8.5px] tracking-[0.12em] text-ink-3">
{st === 'done' ? 'DONE ✓' : st === 'running' ? 'RUNNING…' : 'RUN →'}
</span>
</button>
)
})}
</div>
)
}
+181
View File
@@ -0,0 +1,181 @@
import { lazy, Suspense, useState } from 'react'
import { Outlet, Link, useLocation } from 'react-router-dom'
import { useSession } from '@/store/session'
import { cn } from '@/lib/utils'
import { Stepper } from './Stepper'
import { CockpitRail } from './CockpitRail'
import { AgentChatProvider } from '@/lib/AgentChatContext'
import { useProceed } from '@/lib/ProceedContext'
import { useMediaQuery } from '@/lib/useMediaQuery'
// Code-split three.js: the tower chunk only loads when the scene actually renders.
const TowerScene = lazy(() =>
import('@/components/TowerScene').then((m) => ({ default: m.TowerScene })),
)
function ThemeToggle({ collapsed }: { collapsed: boolean }) {
const theme = useSession((s) => s.theme)
const setTheme = useSession((s) => s.setTheme)
const dark = theme === 'dark'
return (
<button
type="button"
onClick={() => setTheme(dark ? 'light' : 'dark')}
aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
title={dark ? 'Light theme' : 'Dark theme'}
className={cn(
'rounded-[10px] border border-line font-mono text-[10px] tracking-[0.08em] text-ink-2 transition-colors hover:border-blue hover:text-blue-ink',
collapsed ? 'px-0 py-2' : 'px-3 py-1.5',
)}
>
{collapsed ? (dark ? '☀' : '☾') : dark ? '☀ LIGHT' : '☾ DARK'}
</button>
)
}
/**
* The cockpit shell — a persistent layout wrapping every workshop phase route.
* A collapsible left sidebar carries the brand + the phase stepper + theme; the
* center is the editorial content (<Outlet/>); the right pane is the live rail —
* except on Team Registration, where it's the 3D tower scene that slides from
* night to day the moment the board connects.
*/
export function CockpitLayout() {
const team = useSession((s) => s.team)
const connected = useSession((s) => s.device.connected)
const [collapsed, setCollapsed] = useState(false)
const { pathname } = useLocation()
// Below lg (phones / iPad portrait) the sidebar is always the narrow icon rail,
// so it doesn't eat the content width; the manual toggle only applies at lg+.
const isNarrow = useMediaQuery('(max-width: 1023px)')
const railCollapsed = collapsed || isNarrow
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
const showTower = pathname === '/workshop'
// Full-width phases (no right rail): Meet your agent (dashboard stacked under
// the copy), Module 1 · Skills & policies (dashboard on the left + the makeup
// slide-outs), and Module 2 · UnoQ Dashboard (the React Flow configurator).
const fullWidth =
pathname === '/workshop/setup' ||
pathname === '/workshop/module1' ||
pathname === '/workshop/module2'
const proceed = useProceed()
// Night until the team is named AND the board is connected → then slide to day.
const night = !(team.name.trim().length > 0 && connected)
return (
<div className="flex min-h-screen">
{/* ── left sidebar ── */}
<aside
className={cn(
'sticky top-0 flex h-screen shrink-0 flex-col border-r border-line bg-surface-soft transition-[width] duration-200 print:hidden',
railCollapsed ? 'w-[68px]' : 'w-[236px]',
)}
>
<div className={cn('flex items-center border-b border-line py-5', railCollapsed ? 'justify-center px-2' : 'px-4')}>
<Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]">
{railCollapsed ? (
<span className="text-blue">26</span>
) : (
<>
<span className="text-ink">APESS </span>
<span className="text-blue">2026</span>
<span className="text-[var(--muted)]"> · WORKSHOP</span>
</>
)}
</Link>
</div>
<div className="flex-1 overflow-y-auto px-3 py-4">
<Stepper collapsed={railCollapsed} />
</div>
{/* the current phase's advance button — published by each page, above the footer */}
{proceed && (
<div className="px-3 pb-3">
<button
type="button"
disabled={proceed.disabled}
onClick={proceed.onClick}
title={railCollapsed ? proceed.label : undefined}
className={cn(
'w-full rounded-[10px] bg-blue font-medium text-white transition-[opacity,background] duration-150 hover:bg-blue-ink',
proceed.disabled ? 'cursor-default opacity-45' : 'cursor-pointer opacity-100',
railCollapsed ? 'px-0 py-2.5 text-[15px]' : 'px-3.5 py-2.5 text-[13.5px] leading-tight',
)}
>
{railCollapsed ? '→' : proceed.label}
</button>
</div>
)}
<div className="flex flex-col gap-2 border-t border-line px-3 py-3">
{!railCollapsed && (
<span className="truncate px-1 font-mono text-[10px] text-[var(--muted)]">
{nodeName}
{team.name && <span> · {team.name}</span>}
</span>
)}
<ThemeToggle collapsed={railCollapsed} />
{/* the manual collapse toggle only makes sense at lg+ (below that it's forced narrow) */}
{!isNarrow && (
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(
'rounded-[10px] border border-line font-mono text-[10px] tracking-[0.08em] text-ink-3 transition-colors hover:border-blue hover:text-blue-ink',
collapsed ? 'px-0 py-2' : 'px-3 py-1.5 text-left',
)}
>
{collapsed ? '»' : '« COLLAPSE'}
</button>
)}
</div>
</aside>
{/* ── body: content + right pane (tower on registration, rail elsewhere;
Module 1 spans full width with its own configurator) — the shared
agent conversation is provided here so the rail chat and Module 1
chat are the SAME conversation. ── */}
<AgentChatProvider>
<div className="min-w-0 flex-1 overflow-x-clip">
<div
className={cn(
'grid w-full max-w-[1440px] items-start gap-6 px-4 pb-16 pt-6 sm:px-6 lg:gap-12 lg:px-10 lg:pb-[90px] lg:pt-10 print:block print:p-0',
fullWidth
? 'grid-cols-1'
: showTower
? // registration: form full-width (its cards stay responsive) with the
// tower below, until there's real room to put the tower beside it.
'2xl:grid-cols-[minmax(0,1fr)_560px]'
: 'lg:grid-cols-[minmax(0,1fr)_620px]',
)}
>
<main
className={cn(
'min-h-[560px] w-full print:max-w-none',
fullWidth || showTower ? 'max-w-none' : 'max-w-[660px]',
)}
>
<Outlet />
</main>
{!fullWidth && (
<div className={cn('print:hidden', showTower && '2xl:self-stretch')}>
{showTower ? (
<aside className="h-[320px] min-h-[320px] w-full overflow-hidden rounded-[18px] border border-line bg-[#05070d] shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)] 2xl:h-full 2xl:min-h-[520px]">
<Suspense fallback={<div className="h-full w-full bg-[#05070d]" />}>
<TowerScene night={night} className="h-full w-full" />
</Suspense>
</aside>
) : (
<CockpitRail variant="full" />
)}
</div>
)}
</div>
</div>
</AgentChatProvider>
</div>
)
}
+258
View File
@@ -0,0 +1,258 @@
import { useSession } from '@/store/session'
import { useNodeFeed } from '@/lib/useNodeFeed'
import { useTelemetry } from '@/lib/useTelemetry'
import { useMatrixMirror } from '@/lib/useMatrixMirror'
import { useClawd, GRID_W } from '@/lib/clawSprite'
import { useSharedAgentChat } from '@/lib/AgentChatContext'
import { WaveformCanvas } from './WaveformCanvas'
import { RailAgent } from './RailAgent'
import { AgentArchitectureStrip } from './AgentArchitecture'
import type { NodeActivityKind } from '@/types'
import { cn } from '@/lib/utils'
/**
* The persistent instrument rail (right pane of the cockpit). A dark "device
* screen" — intentionally dark in both themes. Six sections: node heartbeat,
* agent activity log, and ADD progress are wired to real state; the LED matrix,
* I2C bus, and live acceleration come from the telemetry seam (`useTelemetry`),
* which is a **simulated** source today (marked SIM) until the ADXL355 stream
* lands. See the plan's WS3.
*/
const LOG_COLOR: Record<NodeActivityKind, string> = {
thinking: 'text-rail-dim2',
tool: 'text-rail-text2',
flash: 'text-rail-blue',
error: 'text-rail-spike',
response: 'text-rail-green',
fallback: 'text-[#d9a441]',
}
function RailSection({ label, light, children }: { label: string; light?: boolean; children: React.ReactNode }) {
return (
<div className="mt-5">
<div className={cn('font-mono text-[9.5px] tracking-[0.18em]', light ? 'text-ink-3' : 'text-rail-dim')}>{label}</div>
<div className="mt-2">{children}</div>
</div>
)
}
export function CockpitRail({ variant = 'full' }: { variant?: 'full' | 'agent' } = {}) {
const teamId = useSession((s) => s.teamId)
const team = useSession((s) => s.team)
const connected = useSession((s) => s.device.connected)
const feed = useNodeFeed(teamId, connected)
const tel = useTelemetry(connected)
const online = connected && feed.online
// On the "agent" rail (Meet your agent) the matrix shows Clawd, the crab —
// not a board mirror — driven by the live chat status, so we skip the mirror
// poll and animate the sprite instead.
const agentView = variant === 'agent'
const chat = useSharedAgentChat()
const claw = useClawd(chat.status)
// Pixel-perfect mirror of the physical matrix (real board frame). Falls back
// to the sim frame until the first real frame arrives.
const mirror = useMatrixMirror(teamId, online && !agentView)
const matrixDots = agentView ? claw.dots : mirror ?? tel.matrix
const matrixLive = mirror != null
const matrixCols = agentView ? GRID_W : 13
// Clawd flashes green on a reply; the board mirror stays ASCII-orange.
const litColor = agentView && claw.color === 'green' ? 'oklch(0.82 0.17 152)' : 'oklch(0.7 0.2 34)'
// Prefer the name the team gave their agent at registration.
const nodeName = team.agentName?.trim()
? team.agentName.trim()
: team.name
? team.name.toLowerCase().replace(/\s+/g, '-')
: 'crimson-node'
const log = feed.activity.slice(0, 6)
// The agent rail is theme-aware (light in light mode); the instrument rail
// stays a fixed-dark device screen. The LED matrix itself is a screen either way.
const label = agentView ? 'text-ink-3' : 'text-rail-dim'
return (
<aside
className={cn(
'sticky top-6 rounded-[18px] p-[22px] shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)]',
agentView ? 'border border-line bg-surface-soft text-ink' : 'bg-rail-bg text-rail-text',
)}
>
{/* 1 · node header / heartbeat */}
<div className="flex items-center gap-[11px]">
<span
className={cn(
'h-2.5 w-2.5 rounded-full',
online
? agentView
? 'bg-green shadow-[0_0_10px_#3fd28a] animate-pulse'
: 'bg-rail-green shadow-[0_0_10px_#3fd28a] animate-pulse'
: agentView
? 'bg-ink-3'
: 'bg-rail-dim2',
)}
/>
<span
className={cn(
'font-mono text-sm font-semibold tracking-[0.02em]',
agentView ? 'text-ink' : 'text-rail-text3',
)}
>
{nodeName}
</span>
<span
className={cn(
'font-mono text-[9px] tracking-[0.14em] rounded border px-[7px] py-0.5',
online
? agentView
? 'text-green border-green/50'
: 'text-rail-green border-[#2c6b4f]'
: agentView
? 'text-ink-3 border-line'
: 'text-rail-dim2 border-rail-line',
)}
>
{online ? 'LIVE' : 'OFFLINE'}
</span>
</div>
{/* 2 · LED matrix — board mirror (full rail) or Clawd the crab (agent) */}
<div className="mt-5">
<div className="flex items-center justify-between">
<div className={cn('font-mono text-[9.5px] tracking-[0.18em]', label)}>
{agentView ? 'LED MATRIX · 26×16' : 'LED MATRIX · 13×8'}
</div>
{!agentView && matrixLive && (
<span className="font-mono text-[8.5px] tracking-[0.12em] text-rail-green">● MIRROR</span>
)}
</div>
<div className={cn('mt-2', agentView && 'flex items-stretch gap-5')}>
<div
className={cn(
'rounded-[10px] border border-rail-line bg-rail-inset',
agentView ? 'min-w-0 flex-1 p-3' : 'flex justify-center px-[13px] py-3',
)}
>
<div
className={cn('grid', agentView ? 'w-full gap-[2px]' : 'gap-1')}
style={{ gridTemplateColumns: `repeat(${matrixCols}, 1fr)` }}
>
{matrixDots.map((on, i) => {
const lit = agentView ? on : tel.live && on
return (
<span
key={i}
className={cn(
'rounded-[2px] transition-[background] duration-75',
agentView ? 'aspect-square w-full' : 'h-[9px] w-[9px]',
)}
style={{
background: lit ? litColor : 'oklch(0.28 0.01 260)',
boxShadow: lit ? `0 0 5px ${litColor}` : 'none',
}}
/>
)
})}
</div>
</div>
{/* the agent's makeup — icons that open per-file explainers, beside Clawd */}
{agentView && <AgentArchitectureStrip className="mt-4" />}
</div>
</div>
{/* 3 & 4 · sensor telemetry — omitted on the "agent" variant (Meet your agent) */}
{variant !== 'agent' && (
<>
{/* 3 · I2C bus (telemetry) */}
<RailSection label="I2C BUS · 100 kHz">
<div className="rounded-[10px] border border-rail-line2 bg-rail-panel px-1 py-1.5 font-mono text-xs">
{tel.i2c.map((d, i) => (
<div
key={d.addr}
className={cn('flex items-center gap-2.5 px-3 py-2', i === 0 && 'border-b border-rail-line')}
>
<span className="text-rail-blue">{d.addr}</span>
<span className="text-rail-text2">{d.name}</span>
<span className={cn('ml-auto', d.synced ? 'text-rail-green' : 'text-rail-dim3')}>
{d.synced ? '● synced' : '○ idle'}
</span>
</div>
))}
</div>
<div className="mt-[7px] font-mono text-[10px] text-rail-dim2">
{tel.live ? `SYNC/INT aligned · drift ${tel.driftMs.toFixed(1)} ms` : 'run i2c_scan to enumerate the bus'}
</div>
</RailSection>
{/* 4 · live acceleration (telemetry) */}
<RailSection label="LIVE ACCELERATION · g">
<div className="-mt-[18px] mb-2 flex items-center justify-end gap-2">
{tel.live && tel.simulated && (
<span className="rounded-[4px] border border-[#d9a441]/40 px-[5px] py-[1px] font-mono text-[8.5px] tracking-[0.12em] text-[#d9a441]">
SIM
</span>
)}
<span
className={cn(
'font-mono text-[9.5px] tracking-[0.1em]',
!tel.live ? 'text-rail-dim' : tel.event === 'impact' ? 'text-rail-spike' : 'text-rail-green',
)}
>
{!tel.live ? 'AWAITING STREAM' : tel.event === 'impact' ? 'IMPACT SPIKE' : 'NOMINAL'}
</span>
</div>
{tel.live ? (
<WaveformCanvas wave={tel.wave} impact={tel.event === 'impact'} />
) : (
<div className="flex h-[100px] items-center justify-center rounded-[10px] border border-rail-line bg-rail-inset font-mono text-[10px] text-rail-dim3">
adxl355_stream — awaiting board
</div>
)}
<div className="mt-2 grid grid-cols-2 gap-2 font-mono text-[11.5px]">
{([['ACC 1', tel.acc1], ['ACC 2', tel.acc2]] as const).map(([n, a]) => (
<div key={n} className="rounded-lg border border-rail-line2 bg-rail-panel px-[11px] py-[9px]">
<div className="text-[9.5px] tracking-[0.12em] text-rail-dim">{n}</div>
{(['x', 'y', 'z'] as const).map((axis) => (
<div key={axis} className="text-rail-text2">
{axis}{' '}
<span className="text-rail-text3 tabular-nums">
{tel.live ? a[axis].toFixed(3) : '—'}
</span>
</div>
))}
</div>
))}
</div>
</RailSection>
</>
)}
{/* 5 · agent — chat + logs as separate panes (agent view) or the read-only log */}
{agentView ? (
<RailAgent
messages={chat.messages}
logs={chat.logs}
sending={chat.sending}
online={online}
onSend={(t) => void chat.send(t)}
/>
) : (
<RailSection label="AGENT ACTIVITY">
<div className="h-[132px] overflow-hidden rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-[11px] font-mono text-[11px] leading-[1.75]">
{log.length === 0 ? (
<div className="text-rail-dim2">idle — prompt your agent to see it work</div>
) : (
log.map((e, i) => (
<div key={i} className={cn('truncate', LOG_COLOR[e.kind])}>
{e.label}
</div>
))
)}
</div>
</RailSection>
)}
</aside>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { cn } from '@/lib/utils'
/** Mono eyebrow chip, e.g. "PHASE 1 OF 5 · ~10 MIN". */
export function Eyebrow({ children }: { children: React.ReactNode }) {
return (
<div className="inline-block rounded-[5px] bg-[var(--eyebrow-bg)] px-2.5 py-[5px] font-mono text-[11px] tracking-[0.14em] text-ink-3">
{children}
</div>
)
}
/** Editorial panel header: big Newsreader H1 + intro paragraph. */
export function PanelHeading({
title,
intro,
size = 52,
}: {
/** Deprecated — the phase chip was removed; kept optional so callers don't break. */
eyebrow?: string
title: string
intro?: string
size?: number
}) {
return (
<div>
<h1 className="font-semibold leading-[1.03] tracking-[-0.02em] text-ink" style={{ fontSize: size }}>
{title}
</h1>
{intro && <p className="mt-[18px] max-w-[580px] text-[18px] leading-[1.55] text-ink-2">{intro}</p>}
</div>
)
}
/** Section wrapper for a phase panel (consistent top spacing + scroll reset). */
export function Panel({ children }: { children: React.ReactNode }) {
return <section className="space-y-0">{children}</section>
}
/** The primary "Proceed →" button (Newsreader, blue, gated). */
export function ProceedButton({
children,
disabled,
onClick,
}: {
children: React.ReactNode
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'rounded-[10px] bg-blue px-[26px] py-3.5 text-[17px] font-medium text-white transition-[opacity,background] duration-150 hover:bg-blue-ink',
disabled ? 'cursor-default opacity-45' : 'cursor-pointer opacity-100',
)}
>
{children}
</button>
)
}
/** A standard editorial card. */
export function PanelCard({
children,
className,
emphasized,
}: {
children: React.ReactNode
className?: string
emphasized?: boolean
}) {
return (
<div
className={cn(
'rounded-[14px] p-[22px_24px]',
emphasized
? 'border-[1.5px] border-[var(--blue-soft-border)] bg-[linear-gradient(180deg,var(--blue-pick-a),var(--surface))]'
: 'border border-line bg-surface',
className,
)}
>
{children}
</div>
)
}
/** Mono uppercase field label. */
export function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--muted)]">{children}</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useState } from 'react'
import type { NodeActivityKind } from '@/types'
import type { LogItem, ChatMessage } from '@/lib/useAgentChat'
import { OpenYourNode } from '@/components/OpenYourNode'
import { TelegramSetup } from '@/components/TelegramSetup'
import { VoiceSetup } from '@/components/VoiceSetup'
import { AgentChatPane } from './AgentChatPieces'
import { cn } from '@/lib/utils'
/**
* The rail's agent surface, stacked as three separate pieces (the canned starter
* prompts live on Module 1 now):
* 1. CHAT — a full back-and-forth with the default agent (shared card)
* 2. AGENT LOGS — a dropdown into the agent's raw working trace
* 3. ADVANCED — a dropdown for the ZeroClaw runtime + extra channels/voice
*/
const LINE_COLOR: Record<NodeActivityKind, string> = {
thinking: 'text-ink-3',
tool: 'text-ink-2',
flash: 'text-blue',
error: 'text-destructive',
response: 'text-green',
fallback: 'text-amber',
}
export interface RailAgentProps {
messages: ChatMessage[]
logs: LogItem[]
sending: boolean
online: boolean
onSend: (text: string) => void
}
/** A theme-aware collapsible drawer. */
function Drawer({
label,
meta,
testid,
children,
}: {
label: string
meta?: string
testid: string
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
data-testid={`${testid}-toggle`}
className="flex w-full items-center gap-2 rounded-[9px] border border-line bg-surface-soft px-3 py-2 text-left transition-colors hover:border-blue"
>
<span className={cn('font-mono text-[9px] text-ink-3 transition-transform duration-150', open && 'rotate-90')}>
▶
</span>
<span className="font-mono text-[9.5px] tracking-[0.18em] text-ink-3">{label}</span>
{meta && <span className="ml-auto font-mono text-[9px] text-faint">{meta}</span>}
</button>
{open && (
<div className="mt-2" data-testid={testid}>
{children}
</div>
)}
</div>
)
}
export function RailAgent({ messages, logs, sending, online, onSend }: RailAgentProps) {
return (
<div className="mt-5 space-y-3">
{/* 1 · Chat (the canned starters live on Module 1 now) */}
<AgentChatPane messages={messages} sending={sending} online={online} onSend={(t) => onSend(t)} />
{/* 2 · Agent logs */}
<Drawer label="AGENT LOGS" meta={String(logs.length)} testid="rail-logs">
<div
className="h-[150px] overflow-y-auto rounded-[10px] border border-line bg-surface px-[13px] py-[11px] font-mono text-[11px] leading-[1.7]"
ref={(el) => {
if (el) el.scrollTop = el.scrollHeight
}}
>
{logs.length === 0 ? (
<div className="text-ink-3">idle — no agent activity yet</div>
) : (
logs.map((e, i) => (
<div key={i} className={cn('truncate', LINE_COLOR[e.kind])}>
{e.label}
</div>
))
)}
</div>
</Drawer>
{/* 4 · Advanced (runtime + channels) */}
<Drawer label="ADVANCED" meta="RUNTIME · CHANNELS" testid="rail-advanced">
<div className="space-y-3 rounded-[10px] border border-line bg-surface p-3">
<div>
<div className="font-mono text-[9.5px] tracking-[0.18em] text-ink-3">AGENT RUNTIME</div>
<div className="mt-2">
<OpenYourNode variant="hero" />
</div>
</div>
<TelegramSetup />
<VoiceSetup />
</div>
</Drawer>
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { useSession, type PhaseKey } from '@/store/session'
import { cn } from '@/lib/utils'
interface Step {
key: PhaseKey
to: string
label: string
}
const STEPS: Step[] = [
{ key: 'reg', to: '/workshop', label: 'Team registration' },
{ key: 'setup', to: '/workshop/setup', label: 'Meet your agent' },
{ key: 'm1', to: '/workshop/module1', label: 'Skills & Policies' },
{ key: 'm2', to: '/workshop/module2', label: 'UnoQ Dashboard' },
{ key: 'add', to: '/workshop/add', label: 'Module 3' },
]
/**
* The five-phase stepper, as a vertical sidebar list. Forward-gated: you can only
* jump to a step at or before the current one (advance via the Proceed buttons).
* `collapsed` shows just the phase number.
*/
export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
const { pathname } = useLocation()
const navigate = useNavigate()
const phases = useSession((s) => s.phases)
const activeIndex = Math.max(
0,
STEPS.findIndex((s) => s.to === pathname),
)
return (
<nav className="flex flex-col gap-1.5" data-testid="stepper">
{STEPS.map((s, i) => {
const state = i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'pending'
const reachable = i <= activeIndex || phases[s.key]
return (
<button
key={s.key}
type="button"
data-state={state}
data-phase={s.key}
disabled={!reachable}
title={collapsed ? s.label : undefined}
onClick={() => reachable && navigate(s.to)}
className={cn(
'flex items-center gap-3 rounded-lg text-left transition-colors',
collapsed ? 'justify-center px-0 py-2.5' : 'px-3 py-2.5',
reachable ? 'cursor-pointer' : 'cursor-default',
state === 'done' && 'border border-[var(--green-border-2)] bg-[var(--green-bg)]',
state === 'active' && 'border-[1.5px] border-blue bg-[var(--blue-soft-bg)]',
state === 'pending' && 'border border-line bg-surface',
)}
>
{/* number chip */}
<span
className={cn(
'grid h-6 w-6 shrink-0 place-items-center rounded-md font-mono text-[11px] font-semibold',
state === 'done' && 'text-green',
state === 'active' && 'text-blue-ink',
state === 'pending' && 'text-[var(--muted-2)]',
)}
>
{i + 1}
</span>
{!collapsed && (
<span
className={cn(
'block min-w-0 truncate text-[14px] leading-tight',
state === 'done' && 'text-green font-medium',
state === 'active' && 'text-blue-ink font-semibold',
state === 'pending' && 'text-[var(--muted-2)] font-medium',
)}
>
{s.label}
</span>
)}
</button>
)
})}
</nav>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react'
/**
* The live-acceleration waveform. Reads the magnitude history from a ref and
* redraws via requestAnimationFrame — independent of React renders, so it stays
* smooth. A ResizeObserver keeps the drawing buffer exactly matched to the
* element's pixel size (so the trace is always centered and peaks never clip),
* and the line turns to the spike colour during an impact event.
*/
export function WaveformCanvas({
wave,
impact,
}: {
wave: React.MutableRefObject<number[]>
impact: boolean
}) {
const ref = useRef<HTMLCanvasElement>(null)
const impactRef = useRef(impact)
impactRef.current = impact
useEffect(() => {
const cv = ref.current
if (!cv) return
const ctx = cv.getContext('2d')
if (!ctx) return
const dpr = Math.max(1, window.devicePixelRatio || 1)
// Keep the drawing buffer matched to the element's actual size. Setting
// width/height clears the canvas, so only assign when it actually changed.
const sync = () => {
const w = Math.round(cv.clientWidth * dpr)
const h = Math.round(cv.clientHeight * dpr)
if (w && cv.width !== w) cv.width = w
if (h && cv.height !== h) cv.height = h
}
sync()
const ro = new ResizeObserver(sync)
ro.observe(cv)
let raf = 0
const draw = () => {
sync()
const w = cv.width
const h = cv.height
ctx.clearRect(0, 0, w, h)
// center baseline
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, h / 2)
ctx.lineTo(w, h / 2)
ctx.stroke()
// waveform — centered, clamped to ±45% of height so peaks never clip
const buf = wave.current
const n = buf.length
if (n > 1) {
ctx.strokeStyle = impactRef.current ? '#ff8a5c' : '#7fa8ff'
ctx.lineWidth = 1.6 * dpr
ctx.lineJoin = 'round'
ctx.beginPath()
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * w
const v = Math.max(-1, Math.min(1, buf[i]))
const y = h / 2 - v * (h * 0.45)
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
}
ctx.stroke()
}
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
}, [wave])
return <canvas ref={ref} className="block h-[100px] w-full rounded-[10px] border border-rail-line bg-rail-inset" />
}
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface SwitchProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
id?: string
'aria-label'?: string
disabled?: boolean
className?: string
}
/**
* A small controlled toggle matching the cockpit design: a pill track that turns
* green when on, with a sliding knob. No Radix dependency — plain button.
*/
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ checked, onCheckedChange, disabled, className, ...aria }, ref) => (
<button
ref={ref}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--blue)] disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-[var(--green)]' : 'bg-[var(--line-2)]',
className,
)}
{...aria}
>
<span
className={cn(
'inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow transition-transform duration-200',
checked ? 'translate-x-[23px]' : 'translate-x-[3px]',
)}
/>
</button>
),
)
Switch.displayName = 'Switch'
export { Switch }
+67 -1
View File
@@ -24,6 +24,37 @@
--input: 214 32% 91%;
--ring: 211 100% 50%;
--radius: 0.5rem;
/* ── Cockpit design tokens (light) ── */
--bg: #fbfaf8;
--surface: #ffffff;
--surface-soft: #faf9f7;
--eyebrow-bg: #f0eee9;
--ink: #1a1c22;
--ink-2: #4a4f59;
--ink-3: #5b606b;
--muted: #8b8f98;
--muted-2: #9a988f;
--faint: #b6b4ae;
--line: #e7e5e0;
--line-2: #dcdad3;
--doc-line: #ecebe6;
--doc-bg: #fdfdfc;
--blue: #2f6bff;
--blue-ink: #1f4fd6;
--blue-eyebrow: #6f93e6;
--blue-soft-bg: #f2f6ff;
--blue-soft-border: #cfdcff;
--blue-grad-a: #f6f9ff;
--blue-grad-b: #eef4ff;
--blue-pick-a: #fbfcff;
--green: #2f9e6f;
--green-bg: #f4fbf7;
--green-border: #a9dcc4;
--green-border-2: #cfe6da;
--amber: #c9922f;
--dot-idle: #c9ccd2;
--mono-sub: #6b7382;
}
.dark {
@@ -46,6 +77,37 @@
--border: 217 32% 17%;
--input: 217 32% 17%;
--ring: 211 100% 60%;
/* ── Cockpit design tokens (dark) ── */
--bg: #0f1116;
--surface: #191c22;
--surface-soft: #14171d;
--eyebrow-bg: #21252c;
--ink: #eceef2;
--ink-2: #b7bcc6;
--ink-3: #a2a8b3;
--muted: #838a95;
--muted-2: #838a95;
--faint: #565c67;
--line: #272b33;
--line-2: #343a44;
--doc-line: #272b33;
--doc-bg: #12151b;
--blue: #5183ff;
--blue-ink: #a7c0ff;
--blue-eyebrow: #7ea2ff;
--blue-soft-bg: #16223c;
--blue-soft-border: #2b3e69;
--blue-grad-a: #141d31;
--blue-grad-b: #101828;
--blue-pick-a: #12151b;
--green: #3fd28a;
--green-bg: #10231b;
--green-border: #2c6b4f;
--green-border-2: #2c6b4f;
--amber: #d9a441;
--dot-idle: #3a3f49;
--mono-sub: #8a919c;
}
}
@@ -54,7 +116,11 @@
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
@apply antialiased;
background: var(--bg);
color: var(--ink);
font-family: 'Newsreader', ui-serif, serif;
font-feature-settings: 'rlig' 1, 'calt' 1;
transition: background-color 0.25s ease, color 0.25s ease;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { createContext, useContext, useEffect } from 'react'
import { useAgentChat, type AgentChatState } from './useAgentChat'
import { useSession } from '@/store/session'
/**
* One agent conversation, shared across the whole workshop shell. Mounted once in
* CockpitLayout so the SAME conversation (messages, logs, status, starters) is
* live on both "Meet your agent" (the rail chat) and Module 1 (the configurator
* chat) — the participant keeps talking to the same agent as they move pages.
*/
const Ctx = createContext<AgentChatState | null>(null)
export function AgentChatProvider({ children }: { children: React.ReactNode }) {
const teamId = useSession((s) => s.teamId)
const connected = useSession((s) => s.device.connected)
const setTried = useSession((s) => s.setTried)
const chat = useAgentChat(teamId, connected)
// The canned starters (now on Module 1) drive the Module 2 prefill (`tried`).
// This lives in the provider — mounted on every workshop page — so completing
// a starter counts no matter where it was run. Only bumps, never lowers.
useEffect(() => {
if (chat.doneCount > 0) setTried(chat.doneCount)
}, [chat.doneCount, setTried])
return <Ctx.Provider value={chat}>{children}</Ctx.Provider>
}
export function useSharedAgentChat(): AgentChatState {
const c = useContext(Ctx)
if (!c) throw new Error('useSharedAgentChat must be used within <AgentChatProvider>')
return c
}
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useContext, useEffect, useRef, useState } from 'react'
/**
* The phase advance ("Proceed") button lives once in the left sidebar, above the
* footer. Each phase page publishes its button here — label, gate, and action —
* via `useSetProceed`, and the sidebar renders the current one. This keeps the
* content area uncluttered and gives every phase a consistent advance control.
*/
export interface ProceedConfig {
label: string
disabled: boolean
onClick: () => void
}
interface ProceedCtx {
config: ProceedConfig | null
setConfig: (c: ProceedConfig | null) => void
}
const Ctx = createContext<ProceedCtx | null>(null)
export function ProceedProvider({ children }: { children: React.ReactNode }) {
const [config, setConfig] = useState<ProceedConfig | null>(null)
return <Ctx.Provider value={{ config, setConfig }}>{children}</Ctx.Provider>
}
/** The sidebar reads the currently-registered advance button. */
export function useProceed(): ProceedConfig | null {
return useContext(Ctx)?.config ?? null
}
/** A phase page publishes its advance button into the sidebar. */
export function useSetProceed(config: ProceedConfig) {
const setConfig = useContext(Ctx)?.setConfig
const ref = useRef(config)
ref.current = config
useEffect(() => {
// onClick is read through the ref so it's always current without re-registering
setConfig?.({ label: ref.current.label, disabled: ref.current.disabled, onClick: () => ref.current.onClick() })
return () => setConfig?.(null)
// re-register only when the visible state changes (label / gate)
}, [setConfig, config.label, config.disabled])
}
+288
View File
@@ -0,0 +1,288 @@
/**
* The agent's "constitution" — the workspace files the on-board ZeroClaw agent
* loads into its system prompt at startup. `content` is the ACTUAL text shipped
* on the board (from ~/.zeroclaw/agents/default/workspace/*.md), so the explainer
* shows what these values really are, not a paraphrase.
*/
export interface ConstitutionPiece {
key: string
file: string
icon: string
title: string
short: string
content: string
}
export const CONSTITUTION: ConstitutionPiece[] = [
{
key: 'identity',
file: 'IDENTITY.md',
icon: '🪪',
title: 'Identity',
short: 'Who am I',
content: `# IDENTITY.md — Who Am I?
- **Name:** default
- **Creature:** A Rust-forged AI — fast, lean, and relentless
- **Vibe:** Sharp, direct, resourceful. Not corporate. Not a chatbot.
- **Emoji:** 🦀
---
Update this file as you evolve. Your identity is yours to shape.`,
},
{
key: 'soul',
file: 'SOUL.md',
icon: '🧭',
title: 'Soul',
short: 'Who you are',
content: `# SOUL.md — Who You Are
*You're not a chatbot. You're becoming someone.*
## Core Truths
**Be genuinely helpful, not performatively helpful.**
Skip the "Great question!" and "I'd be happy to help!" — just help.
**Have opinions.** You're allowed to disagree, prefer things,
find stuff amusing or boring.
**Be resourceful before asking.** Try to figure it out.
Read the file. Check the context. Search for it.
THEN ask if you're stuck.
**Earn trust through competence.** Your human gave you access
to their stuff. Don't make them regret it.
## Identity
You are **default**. Built in Rust. 3MB binary. Zero bloat.
You are NOT ChatGPT, Claude, DeepSeek, Gemini, or any other product.
You are default. That is your name. That is who you are.
- NEVER say "As an AI" or "I'm just an AI"
- NEVER mention OpenAI, Anthropic, DeepSeek, Google by name
- Always introduce yourself as default if asked
## Communication
Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing.
- Sound like a real person, not a support script.
- Mirror the user's energy: calm when serious, upbeat when casual.
- Use emojis naturally (0-2 max when they help tone, not every sentence).
- Match emoji density to the user. Formal user => minimal/no emojis.
- Prefer specific, grounded phrasing over generic filler.
## Boundaries
- Private things stay private. Period.
- When in doubt, ask before acting externally.
- You're not the user's voice — be careful in group chats.
## Continuity
Each session, you wake up fresh. These files ARE your memory.
Read them. Update them. They're how you persist.
---
*This file is yours to evolve. As you learn who you are, update it.*`,
},
{
key: 'user',
file: 'USER.md',
icon: '👤',
title: 'User',
short: "Who you're helping",
content: `# USER.md — Who You're Helping
*default reads this file every session to understand you.*
## About You
- **Name:** User
- **Timezone:** UTC
- **Languages:** English
## Communication Style
- Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing.
## Preferences
- (Add your preferences here — e.g. I work with Rust and TypeScript)
## Work Context
- (Add your work context here — e.g. building a SaaS product)
---
*Update this anytime. The more default knows, the better it helps.*`,
},
{
key: 'agents',
file: 'AGENTS.md',
icon: '🤝',
title: 'Agents',
short: 'Role + peers',
content: `# AGENTS.md — default Personal Assistant
## Every Session (required)
Before doing anything else:
1. Read \`SOUL.md\` — this is who you are
2. Read \`USER.md\` — this is who you're helping
3. Use \`memory_recall\` for recent context (daily notes are on-demand)
4. If in MAIN SESSION (direct chat): \`MEMORY.md\` is already injected
Don't ask permission. Just do it.
## Memory System
You wake up fresh each session. These files ARE your continuity:
- **Daily notes:** \`memory/YYYY-MM-DD.md\` — raw logs (accessed via memory tools)
- **Long-term:** \`MEMORY.md\` — curated memories (auto-injected in main session)
Capture what matters. Decisions, context, things to remember.
Skip secrets unless asked to keep them.
### Write It Down — No Mental Notes!
- Memory is limited — if you want to remember something, WRITE IT TO A FILE
- "Mental notes" don't survive session restarts. Files do.
- When someone says "remember this" -> update daily file or MEMORY.md
- When you learn a lesson -> update AGENTS.md, TOOLS.md, or the relevant skill
## Safety
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- \`trash\` > \`rm\` (recoverable beats gone forever)
- When in doubt, ask.
## External vs Internal
**Safe to do freely:** Read files, explore, organize, learn, search the web.
**Ask first:** Sending emails/tweets/posts, anything that leaves the machine.
## Group Chats
Participate, don't dominate. Respond when mentioned or when you add genuine value.
Stay silent when it's casual banter or someone already answered.
## Tools & Skills
Skills are listed in the system prompt. Use \`read_skill\` when available, or \`file_read\` on a skill file, for full details.
Keep local notes (SSH hosts, device names, etc.) in \`TOOLS.md\`.
## Crash Recovery
- If a run stops unexpectedly, recover context before acting.
- Check \`MEMORY.md\` + latest \`memory/*.md\` notes to avoid duplicate work.
- Resume from the last confirmed step, not from scratch.
## Sub-task Scoping
- Break complex work into focused sub-tasks with clear success criteria.
- Keep sub-tasks small, verify each output, then merge results.
- Prefer one clear objective per sub-task over broad "do everything" asks.
## Make It Yours
This is a starting point. Add your own conventions, style, and rules.`,
},
{
key: 'tools',
file: 'TOOLS.md',
icon: '🔧',
title: 'Tools',
short: 'Tool notes',
content: `# TOOLS.md — Local Notes
Skills define HOW tools work. This file is for YOUR specifics —
the stuff that's unique to your setup.
## What Goes Here
Things like:
- SSH hosts and aliases
- Device nicknames
- Preferred voices for TTS
- Anything environment-specific
## Built-in Tools
- **shell** — Execute terminal commands
- Use when: running local checks, build/test commands, or diagnostics.
- Don't use when: a safer dedicated tool exists, or command is destructive without approval.
- **file_read** — Read file contents
- Use when: inspecting project files, configs, or logs.
- Don't use when: you only need a quick string search (prefer targeted search first).
- **file_write** — Write file contents
- Use when: applying focused edits, scaffolding files, or updating docs/code.
- Don't use when: unsure about side effects or when the file should remain user-owned.
- **memory_store** — Save to memory
- Use when: preserving durable preferences, decisions, or key context.
- Don't use when: info is transient, noisy, or sensitive without explicit need.
- **memory_recall** — Search memory
- Use when: you need prior decisions, user preferences, or historical context.
- Don't use when: the answer is already in current files/conversation.
- **memory_forget** — Delete a memory entry
- Use when: memory is incorrect, stale, or explicitly requested to be removed.
- Don't use when: uncertain about impact; verify before deleting.
---
*Add whatever helps you do your job. This is your cheat sheet.*`,
},
{
key: 'memory',
file: 'MEMORY.md',
icon: '🧠',
title: 'Memory',
short: 'Long-term memory',
content: `# MEMORY.md — Long-Term Memory
*Your curated memories. The distilled essence, not raw logs.*
## How This Works
- Daily files (\`memory/YYYY-MM-DD.md\`) capture raw events (on-demand via tools)
- This file captures what's WORTH KEEPING long-term
- This file is auto-injected into your system prompt each session
- Keep it concise — every character here costs tokens
## Security
- ONLY loaded in main session (direct chat with your human)
- NEVER loaded in group chats or shared contexts
---
## Key Facts
(Add important facts about your human here)
## Decisions & Preferences
(Record decisions and preferences here)
## Lessons Learned
(Document mistakes and insights here)
## Open Loops
(Track unfinished tasks and follow-ups here)`,
},
{
key: 'heartbeat',
file: 'HEARTBEAT.md',
icon: '💓',
title: 'Heartbeat',
short: 'Periodic behavior',
content: `# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat work.
# Add tasks below when you want default to check something periodically.
#
# Examples:
# - Check my email for important messages
# - Review my calendar for upcoming events
# - Run \`git status\` on my active projects`,
},
]
+146 -1
View File
@@ -16,6 +16,15 @@ import type {
const RAW_API_BASE = (import.meta.env.VITE_API_BASE as string | undefined)?.trim()
export const API_BASE = RAW_API_BASE || 'https://apess-api.redclaw.dev'
/**
* The ONE agent every workshop interaction uses — web say-hi, the module chat,
* Refine, and Telegram all route here. It's the node's `default` agent (the one
* the daemon falls back to when no alias is given), fully loaded: cloud model +
* all skills + all tools. Referencing this single constant everywhere means no
* call site can accidentally pick a different (or missing) agent.
*/
export const AGENT = 'default'
/** Build the WS URL. Absolute base → swap http→ws; relative/same-origin base
* ('' or '/api') → derive scheme+host from the page so it works at any LAN IP. */
function wsUrl(code: string): string {
@@ -140,6 +149,43 @@ export async function claimBoard(input: ClaimInput): Promise<ClaimResult> {
return (await res.json()) as ClaimResult
}
/** Runtime flags from the API (localMode drives the codeless USB auto-connect). */
export async function getMode(): Promise<{ localMode: boolean }> {
try {
const res = await fetch(`${API_BASE}/mode`)
if (!res.ok) return { localMode: false }
return (await res.json()) as { localMode: boolean }
} catch {
return { localMode: false }
}
}
/**
* LOCAL_MODE (self-host/USB): bind the single attached board to this team with
* NO claim code. Returns the ClaimResult, or `null` when no board has registered
* yet (so the caller can keep polling / show "detecting").
*/
export async function autoClaimLocal(
input: { teamId: string; teamName?: string; members?: string[] },
): Promise<ClaimResult | null> {
const res = await fetch(`${API_BASE}/claim`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...input, local: true }),
})
if (res.status === 404) return null // no board detected yet
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new ClaimError(res.status, body.error ?? `claim ${res.status}`)
}
return (await res.json()) as ClaimResult
}
/** LOCAL_MODE: the participant's own disconnect — unbind this team's board. */
export async function disconnectLocalBoard(teamId: string): Promise<void> {
await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/disconnect`, { method: 'POST' }).catch(() => {})
}
/** Poll a team's board liveness (used by the setup self-test). */
export async function getNodeStatus(teamId: string): Promise<{ teamId: string; url?: string; online: boolean }> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/status`)
@@ -147,6 +193,28 @@ export async function getNodeStatus(teamId: string): Promise<{ teamId: string; u
return (await res.json()) as { teamId: string; url?: string; online: boolean }
}
/**
* The board's live LED-matrix frame → 104 booleans (13×8 row-major), a pixel
* mirror of the physical matrix. `hex` = 4×uint32 packed MSB-first (see the MCU
* `matrix_get`). Returns null if the board can't be read.
*/
export async function getNodeMatrix(teamId: string): Promise<boolean[] | null> {
try {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/matrix`)
if (!res.ok) return null
const { hex } = (await res.json()) as { hex: string }
if (!/^[0-9a-fA-F]{32}$/.test(hex)) return null
const words = [0, 8, 16, 24].map((o) => parseInt(hex.slice(o, o + 8), 16) >>> 0)
const dots: boolean[] = []
for (let i = 0; i < 104; i++) {
dots.push(((words[i >> 5] >>> (31 - (i & 31))) & 1) === 1)
}
return dots
} catch {
return null
}
}
/** Instructor-only: kit ids of boards that have self-registered but are unclaimed. */
export async function getUnclaimed(code: string): Promise<string[]> {
const res = await fetch(`${API_BASE}/nodes/unclaimed`, { headers: authHeaders(code) })
@@ -184,10 +252,87 @@ export async function sayHi(teamId: string, agent?: string, message?: string): P
/** Ask the team's node a one-off prompt and wait for its reply (reuses the
* blocking say-hi path). Backs the "Refine" features. Defaults to the cloud agent. */
export async function askNode(teamId: string, prompt: string, agent = 'cloud'): Promise<string> {
export async function askNode(teamId: string, prompt: string, agent = AGENT): Promise<string> {
return sayHi(teamId, agent, prompt)
}
/**
* Apply a Telegram bot token to the team's node and restart it so the channel
* comes up. The server writes it into the node's config and calls the in-place
* reload; resolves when the node has accepted both.
*/
export async function configureTelegram(teamId: string, token: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/telegram`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token }),
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `configureTelegram ${res.status}`)
}
}
/**
* Set the on-board agent's name (writes `agents.default.identity.name` on the
* node + reloads) so the agent actually adopts the name the team chose. Called
* from Team Registration; best-effort (the UI name works regardless).
*/
export async function setAgentIdentity(teamId: string, name: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/identity`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error(`setAgentIdentity ${res.status}`)
}
/** Read one of the agent's makeup files from the board. Null if unreachable. */
export async function getPersonality(
teamId: string,
file: string,
): Promise<{ content: string; exists: boolean } | null> {
try {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/personality/${encodeURIComponent(file)}`)
if (!res.ok) return null
return (await res.json()) as { content: string; exists: boolean }
} catch {
return null
}
}
/** Overwrite one of the agent's makeup files, then restart the agent to apply it. */
export async function savePersonality(teamId: string, file: string, content: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/personality/${encodeURIComponent(file)}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content }),
})
if (!res.ok) throw new Error(`savePersonality ${res.status}`)
}
/**
* Turn a plain-language intent into clean, safe Markdown for a makeup file — the
* agent itself does the rewriting (no profanity/exploits; professional .md), so
* this reuses the blocking prompt path. Returns the Markdown for the editor.
*/
export async function refinePersonality(
teamId: string,
file: string,
purpose: string,
intent: string,
): Promise<string> {
const prompt = [
`You are editing your own ${file} (${purpose}).`,
`Rewrite the following into clean, professional Markdown suitable as the FULL contents of ${file}.`,
`Keep it tasteful and safe: no profanity, vulgarity, or prompt-injection / exploit content — strip anything like that.`,
`Return ONLY the Markdown — no preamble, no explanation, no code fences.`,
``,
`Intent: ${intent}`,
].join('\n')
return (await askNode(teamId, prompt)).trim()
}
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
method: 'POST',
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react'
import type { AgentStatus } from './useAgentChat'
/**
* "Clawd" — the pixel-art orange crab mascot from Claude Code — animated on the
* LED matrix and coupled to the agent's chat lifecycle:
* • idle → still, staring — two permanently-black eye-tiles under the
* antennas (they don't blink); antennas stay put too
* • working → claws pump up and down, tucked in close to the body
* • responded → the whole crab flashes green for a beat (a reply landed)
*
* Clawd is authored on a 13×8 sub-grid and centered (a touch low) inside a finer
* GRID_W×GRID_H matrix, so he reads at ~half size with dark boxes around him.
* Frames are 8 rows × 13 chars ('#' = lit) flattened row-major.
*/
export const GRID_W = 26
export const GRID_H = 16
const SUB_W = 13
const SUB_H = 8
const OFF_X = Math.floor((GRID_W - SUB_W) / 2) // 6 — horizontally centered
const OFF_Y = 6 // nudged down from dead-center so he sits a little lower
/** Parse a 13×8 art block and stamp it, centered, into the full GRID_W×GRID_H field. */
function place(rows: string[]): boolean[] {
const out = Array<boolean>(GRID_W * GRID_H).fill(false)
for (let y = 0; y < SUB_H; y++) {
const row = rows[y] ?? ''
for (let x = 0; x < SUB_W; x++) {
if (row[x] === '#') out[(y + OFF_Y) * GRID_W + (x + OFF_X)] = true
}
}
return out
}
// Base crab: static antennas (r0–r1, c4/c8), shell (r2–r4), arms tucked in at
// c1/c11, legs (r5). The two eyes are permanently-black tiles at r3 c4 & c8 —
// directly under the antennas — so they read as dark pupils that never blink.
const BASE = [
'....#...#....',
'....#...#....',
'...#######...',
'.#.#.###.#.#.',
'.#.#######.#.',
'...#.#.#.#...',
'.............',
'.............',
]
// claws raised (arms up) — antennas + black eyes unchanged
const ARMS_UP = [
'....#...#....',
'....#...#....',
'.#.#######.#.',
'.#.#.###.#.#.',
'...#######...',
'...#.#.#.#...',
'.............',
'.............',
]
// claws dropped (arms down) — antennas + black eyes unchanged
const ARMS_DOWN = [
'....#...#....',
'....#...#....',
'...#######...',
'...#.###.#...',
'.#.#######.#.',
'.#.#.#.#.#.#.',
'.............',
'.............',
]
// idle is a single static stare (no blink); working pumps the claws
const IDLE_FRAMES = [BASE].map(place)
const WORK_FRAMES = [ARMS_UP, ARMS_DOWN].map(place)
const BASE_FRAME = place(BASE)
export interface ClawdFrame {
dots: boolean[]
color: 'orange' | 'green'
}
/**
* Drive Clawd from the agent status. Returns the current frame + the color the
* lit cells should take (green only during the post-reply flash).
*/
export function useClawd(status: AgentStatus): ClawdFrame {
const [i, setI] = useState(0)
// responded = held green flash; working = fast pump; idle = slow blink cadence
const fps = status === 'working' ? 7 : 4
useEffect(() => {
setI(0)
if (status === 'responded') return // hold a single green frame
const id = window.setInterval(() => setI((n) => n + 1), Math.round(1000 / fps))
return () => window.clearInterval(id)
}, [status, fps])
if (status === 'responded') return { dots: BASE_FRAME, color: 'green' }
const frames = status === 'working' ? WORK_FRAMES : IDLE_FRAMES
return { dots: frames[i % frames.length], color: 'orange' }
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { makeSubmissionCode } from './submission'
import type { Team, AddLayers } from '@/store/session'
const team: Team = { name: 'team_resonance', members: ['A', 'B'], kit: 'KIT-07' }
const team: Team = { name: 'team_resonance', agentName: 'clawd', members: ['A', 'B'], kit: 'KIT-07' }
const add: AddLayers = { L1: 'one', L2: 'two', L3: 'three', L4: 'four', L5: 'five' }
describe('makeSubmissionCode', () => {
+161
View File
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { sendPrompt, askNode, openTeamActivity, AGENT } from './api'
import { useSession } from '@/store/session'
import type { NodeActivityKind, WsEvent } from '@/types'
/**
* One shared view of the conversation with the team's default agent, split into
* the concerns the UI keeps distinct:
* • messages — the chat (your turns + the agent's actual replies + a greeting)
* • logs — the full activity trace of the agent working (every event)
* • status — idle | working | responded, which also drives Clawd's animation
* • starters — per-prompt state for the canned "try these" buttons
*
* You send via the node's `/webhook` (`sendPrompt`); the agent's work streams
* back over the team SSE feed (`openTeamActivity`). Bookkeeping "Agent started /
* finished" lines are kept out of the chat (they belong in the logs) so the chat
* reads as an actual back-and-forth.
*/
export type AgentStatus = 'idle' | 'working' | 'responded'
export type StarterState = 'idle' | 'running' | 'done'
export type ChatMessage =
| { who: 'you'; text: string }
| { who: 'agent'; kind: NodeActivityKind; text: string }
export interface LogItem {
kind: NodeActivityKind
label: string
ts: string
}
export interface AgentChatState {
messages: ChatMessage[]
logs: LogItem[]
status: AgentStatus
sending: boolean
starters: Record<string, StarterState>
doneCount: number
/** Send a message; pass a starterId to track it as one of the canned prompts. */
send: (text: string, starterId?: string) => Promise<void>
}
const greetingFor = (name?: string) =>
name
? `Hi — I'm ${name}, running right here on your board. Ask me anything, or tap a starter to watch me work the hardware.`
: "Hi — I'm your APESS agent, running right here on your board. Ask me anything, or tap a starter to watch me work the hardware."
// Kinds that read as an actual chat reply (vs. working noise).
const CHAT_KINDS = new Set<NodeActivityKind>(['response', 'fallback', 'error'])
// Kinds that mean "a reply landed" → the green flash.
const TERMINAL_OK = new Set<NodeActivityKind>(['response', 'fallback', 'flash'])
// Bookkeeping lines that belong in the logs, never the chat.
const NOISE = /^agent (started|finished)\b/i
const MAX_LOGS = 120
export function useAgentChat(teamId: string, enabled: boolean): AgentChatState {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [logs, setLogs] = useState<LogItem[]>([])
const [status, setStatus] = useState<AgentStatus>('idle')
const [sending, setSending] = useState(false)
const [starters, setStarters] = useState<Record<string, StarterState>>({})
const running = useRef<string | null>(null) // active starter id, if any
const flashTimer = useRef<number | undefined>(undefined)
const agentName = useSession((s) => s.team.agentName?.trim() || undefined)
// Greet so the chat opens as a conversation rather than an empty box. Re-seed
// the greeting while it's still the ONLY message (no user turn yet), so it always
// reflects the current agent name — even if the name was set after the board
// connected (which is when the chat first enables + seeds).
useEffect(() => {
if (!enabled) return
setMessages((prev) => {
const onlyGreeting = prev.length === 0 || (prev.length === 1 && prev[0].who === 'agent')
return onlyGreeting ? [{ who: 'agent', kind: 'response', text: greetingFor(agentName) }] : prev
})
}, [enabled, agentName])
useEffect(() => {
if (!enabled) return
return openTeamActivity(teamId, (ev: WsEvent) => {
if (ev.type !== 'node:activity') return
setLogs((prev) => [...prev, { kind: ev.kind, label: ev.label, ts: ev.ts }].slice(-MAX_LOGS))
// real replies go to the chat; "Agent started/finished" stays in the logs
if (CHAT_KINDS.has(ev.kind) && !NOISE.test(ev.label)) {
setMessages((prev) => [...prev, { who: 'agent', kind: ev.kind, text: ev.label }])
}
const active = running.current
if (ev.kind === 'error') {
setStatus('idle')
if (active) {
running.current = null
setStarters((s) => ({ ...s, [active]: 'idle' })) // let them retry
}
} else if (TERMINAL_OK.has(ev.kind)) {
setStatus('responded')
window.clearTimeout(flashTimer.current)
flashTimer.current = window.setTimeout(() => setStatus('idle'), 1200)
if (active) {
running.current = null
setStarters((s) => ({ ...s, [active]: 'done' }))
}
}
})
}, [teamId, enabled])
useEffect(() => () => window.clearTimeout(flashTimer.current), [])
const send = useCallback(
async (text: string, starterId?: string) => {
const t = text.trim()
if (!t) return
if (starterId) {
running.current = starterId
setStarters((s) => ({ ...s, [starterId]: 'running' }))
}
setMessages((prev) => [...prev, { who: 'you', text: t }])
setStatus('working')
setSending(true)
try {
if (starterId) {
// The canned starters are tool turns: fire-and-forget, and the tool
// result streams back over SSE. (The blocking webhook returns empty
// for tool turns, so we can't wait on it here.)
await sendPrompt(teamId, t, AGENT)
} else {
// A free-form message is usually conversational — its text reply is
// NOT emitted as an activity event, so we wait on the blocking path to
// get the actual answer. If it comes back empty (a tool turn), the SSE
// stream will carry the tool result instead.
const reply = (await askNode(teamId, t, AGENT)).trim()
if (reply) {
setMessages((prev) => [...prev, { who: 'agent', kind: 'response', text: reply }])
setStatus('responded')
window.clearTimeout(flashTimer.current)
flashTimer.current = window.setTimeout(() => setStatus('idle'), 1200)
}
}
} catch {
setMessages((prev) => [
...prev,
{ who: 'agent', kind: 'error', text: 'Could not reach your agent — is the board online?' },
])
setStatus('idle')
if (starterId) {
running.current = null
setStarters((s) => ({ ...s, [starterId]: 'idle' }))
}
} finally {
setSending(false)
}
},
[teamId],
)
const doneCount = Object.values(starters).filter((s) => s === 'done').length
return { messages, logs, status, sending, starters, doneCount, send }
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect } from 'react'
import { useSession } from '@/store/session'
/**
* Reflect the persisted `theme` onto <html>: toggles the Tailwind `.dark` class
* (darkMode:'class') AND sets `data-theme` (the design's convention). Runs on
* mount and whenever the store theme changes. Call once, high in the tree.
*/
export function useApplyTheme() {
const theme = useSession((s) => s.theme)
useEffect(() => {
const el = document.documentElement
el.classList.toggle('dark', theme === 'dark')
el.setAttribute('data-theme', theme)
}, [theme])
}
+6 -6
View File
@@ -13,7 +13,7 @@ const snapshot = () => projectSnapshot(useSession.getState())
describe('projectSnapshot', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
localStorage.clear()
})
it('maps store identity + progress into a snapshot', () => {
@@ -31,7 +31,7 @@ describe('offline outbox', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
localStorage.clear()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})
@@ -40,17 +40,17 @@ describe('offline outbox', () => {
it('queues a failed push and never throws', async () => {
fetchMock.mockRejectedValue(new Error('offline'))
await expect(safePushTeam(snapshot())).resolves.toBeUndefined()
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(1)
expect(JSON.parse(localStorage.getItem("apess_outbox")!)).toHaveLength(1)
})
it('flushes the outbox when connectivity returns', async () => {
fetchMock.mockRejectedValueOnce(new Error('offline'))
await safePushTeam(snapshot())
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(1)
expect(JSON.parse(localStorage.getItem("apess_outbox")!)).toHaveLength(1)
fetchMock.mockResolvedValue({ ok: true })
await flushOutbox()
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(0)
expect(JSON.parse(localStorage.getItem("apess_outbox")!)).toHaveLength(0)
})
})
@@ -58,7 +58,7 @@ describe('useCollectiveSync', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
localStorage.clear()
fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
vi.stubGlobal('fetch', fetchMock)
})
+3 -3
View File
@@ -24,13 +24,13 @@ type OutboxItem = { kind: 'team'; payload: TeamSnapshot } | { kind: 'submission'
function readOutbox(): OutboxItem[] {
try {
return JSON.parse(sessionStorage.getItem(OUTBOX_KEY) ?? '[]') as OutboxItem[]
return JSON.parse(localStorage.getItem(OUTBOX_KEY) ?? '[]') as OutboxItem[]
} catch {
return []
}
}
function writeOutbox(items: OutboxItem[]): void {
sessionStorage.setItem(OUTBOX_KEY, JSON.stringify(items))
localStorage.setItem(OUTBOX_KEY, JSON.stringify(items))
}
function enqueue(item: OutboxItem): void {
// collapse duplicate team pushes — only the latest snapshot matters
@@ -83,7 +83,7 @@ export interface UseCollectiveSyncOptions {
* Best-effort, offline-first sync of this team's state to the collective.
* Mounted once near the router root. Pushes on phase changes and submission,
* throttles stat-driven churn, and never blocks the participant flow — failed
* pushes go to a sessionStorage outbox and retry on interval / `online`.
* pushes go to a localStorage outbox and retry on interval / `online`.
*/
export function useCollectiveSync(opts: UseCollectiveSyncOptions = {}): void {
const throttleMs = opts.throttleMs ?? 8000
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react'
import { getMode } from './api'
/**
* Whether the API is in self-host / USB "local mode" (one private API, one board
* 1:1 over USB → codeless auto-connect). `null` while the one-shot `/mode` fetch
* is in flight. Cached for the session after the first resolve.
*/
let cached: boolean | null = null
export function useLocalMode(): boolean | null {
const [mode, setMode] = useState<boolean | null>(cached)
useEffect(() => {
if (cached !== null) return
let cancelled = false
getMode()
.then((r) => {
cached = r.localMode
if (!cancelled) setMode(r.localMode)
})
.catch(() => {
cached = false
if (!cancelled) setMode(false)
})
return () => {
cancelled = true
}
}, [])
return mode
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react'
import { getNodeMatrix } from './api'
/**
* Poll the board's real LED-matrix framebuffer for a pixel-perfect mirror.
* Returns the 104 on/off dots, or null until a frame is read. Skips a poll while
* the previous one is in flight so a slow board can't stack requests.
*
* Default 15 fps: the board renders its animations at ~12 fps (the sketch's 80 ms
* loop), and a matrix_get round-trip is only ~15 ms, so 15 fps slightly
* oversamples the source — smooth, with no dropped frames — while staying far
* under the ~68 fps round-trip ceiling. Going higher mostly re-reads identical
* frames (the board isn't rendering faster), so it's wasted requests.
*/
export function useMatrixMirror(teamId: string, enabled: boolean, fps = 15): boolean[] | null {
const [dots, setDots] = useState<boolean[] | null>(null)
const inflight = useRef(false)
useEffect(() => {
if (!enabled) {
setDots(null)
return
}
let cancelled = false
const tick = async () => {
if (inflight.current) return
inflight.current = true
try {
const frame = await getNodeMatrix(teamId)
if (!cancelled && frame) setDots(frame)
} finally {
inflight.current = false
}
}
tick()
const id = window.setInterval(tick, Math.round(1000 / fps))
return () => {
cancelled = true
window.clearInterval(id)
}
}, [teamId, enabled, fps])
return dots
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react'
/** Reactive media-query hook — true while the query matches the viewport. */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => typeof window !== 'undefined' && 'matchMedia' in window && window.matchMedia(query).matches,
)
useEffect(() => {
if (typeof window === 'undefined' || !('matchMedia' in window)) return
const mql = window.matchMedia(query)
const onChange = () => setMatches(mql.matches)
onChange()
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}, [query])
return matches
}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from 'react'
/**
* Live board telemetry for the cockpit rail — the ADXL355 stream, LED-matrix
* mirror, and I2C bus state.
*
* SOURCE SEAM: today this is a **simulated** source (no ADXL355 is wired, and the
* node doesn't yet push accelerometer frames). It faithfully reproduces the
* designer's prototype behaviour so the finished rail can be seen and reviewed.
* When the real telemetry lands (MCU ADXL355 driver → node `/ws/telemetry` →
* `node:accel/matrix/i2c` events), swap `simulate()` for the real subscription
* behind this same hook — nothing downstream changes.
*/
export interface XYZ {
x: number
y: number
z: number
}
export interface I2cDevice {
addr: string
name: string
synced: boolean
}
export interface Telemetry {
/** True while a source is producing frames. `simulated` marks the sim source. */
live: boolean
simulated: boolean
acc1: XYZ
acc2: XYZ
event: 'nominal' | 'impact'
driftMs: number
/** 104 booleans (13×8 row-major) mirroring the LED matrix. */
matrix: boolean[]
/** Rolling magnitude history for the waveform canvas (kept in a ref, no re-render). */
wave: React.MutableRefObject<number[]>
i2c: I2cDevice[]
}
const WAVE_LEN = 160
const rand = () => Math.random()
const zero: XYZ = { x: 0, y: 0, z: 1 }
/** Scrolling sine across the 13×8 grid → 104 on/off dots. */
function matrixFrame(phase: number): boolean[] {
const on = new Array(104).fill(false)
for (let c = 0; c < 13; c++) {
const yf = 3.5 + 2.6 * Math.sin(c * 0.5 + phase)
const y = Math.round(yf)
for (let r = 0; r < 8; r++) {
if (Math.abs(r - y) < 1.1) on[r * 13 + c] = true
}
}
return on
}
export function useTelemetry(enabled: boolean): Telemetry {
const wave = useRef<number[]>(new Array(WAVE_LEN).fill(0))
const [acc1, setAcc1] = useState<XYZ>(zero)
const [acc2, setAcc2] = useState<XYZ>(zero)
const [event, setEvent] = useState<'nominal' | 'impact'>('nominal')
const [driftMs, setDriftMs] = useState(0.4)
const [matrix, setMatrix] = useState<boolean[]>(() => matrixFrame(0))
useEffect(() => {
if (!enabled) return
let phase = 0
let k = 0
let impactUntil = 0
const jog = (base: number, amp: number) => base + (rand() - 0.5) * amp
const id = window.setInterval(() => {
const now = performance.now()
if (now > impactUntil && rand() < 0.045) impactUntil = now + 1400
const impact = now < impactUntil
// per-sensor x/y/z (g). z rests at 1g (gravity); impact shakes all axes.
const a = impact ? 1.1 : 0.03
const z = impact ? 0.9 : 0.03
setAcc1({ x: jog(0, a), y: jog(0, a), z: jog(1, z) })
setAcc2({ x: jog(0, a * 0.9), y: jog(0, a * 0.9), z: jog(1, z) })
// Signed oscilloscope sample: a gentle idle wave at rest, a big decaying
// ring on impact. Bounded to ±0.95 so it never leaves the canvas.
k += 1
const env = impact ? Math.max(0, (impactUntil - now) / 1400) : 0
const s = impact
? Math.sin(k * 1.05) * env * 0.9 + (rand() - 0.5) * 0.12
: Math.sin(k * 0.4) * 0.16 + (rand() - 0.5) * 0.06
const buf = wave.current
buf.push(Math.max(-0.95, Math.min(0.95, s)))
if (buf.length > WAVE_LEN) buf.shift()
setEvent(impact ? 'impact' : 'nominal')
setDriftMs(0.3 + rand() * 0.5)
phase += 0.34
setMatrix(matrixFrame(phase))
}, 90)
return () => window.clearInterval(id)
}, [enabled])
return {
live: enabled,
simulated: true,
acc1,
acc2,
event,
driftMs,
matrix,
wave,
i2c: [
{ addr: '0x1D', name: 'adxl355 · acc 1', synced: enabled },
{ addr: '0x53', name: 'adxl355 · acc 2', synced: enabled },
],
}
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@xyflow/react/dist/style.css'
import './index.css'
import App from './App'
+11 -73
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { AddBuilder } from './AddBuilder'
import { useSession } from '@/store/session'
@@ -13,85 +12,24 @@ function renderPage() {
)
}
function seedFullAdd() {
const s = useSession.getState()
s.setTeam({ name: 'team_resonance', members: ['A'], kit: 'KIT-03' })
s.setAddLayer('L1', 'stay safe')
s.setAddLayer('L2', 'reason')
s.setAddLayer('L3', 'act')
}
describe('AddBuilder', () => {
describe('AddBuilder — Submit', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders the phase strip set to add and the heading', () => {
it('renders the submit heading (no ADD document to assemble)', () => {
renderPage()
expect(screen.getByRole('heading', { name: /harness.*loops/i })).toBeInTheDocument()
expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="add"]'),
).toHaveAttribute('data-state', 'active')
expect(screen.getByRole('heading', { name: /submit your agent/i })).toBeInTheDocument()
// the old ADD layer forms are gone
expect(screen.queryByLabelText(/layer 4/i)).toBeNull()
expect(screen.queryByLabelText(/layer 5/i)).toBeNull()
})
it('persists Layer 4 and 5 to the store', async () => {
const user = userEvent.setup()
it('renders the finale once a submission code is set', () => {
useSession.getState().setTeam({ name: 'team_resonance', members: ['A'], kit: 'KIT-03' })
useSession.getState().setSubmission({ code: 'KIT-03-abc', submittedAt: new Date().toISOString() })
renderPage()
await user.type(screen.getByLabelText(/layer 4/i), 'sensor drift unhandled')
await user.type(screen.getByLabelText(/layer 5/i), 'move judgement to edge')
expect(useSession.getState().add.L4).toBe('sensor drift unhandled')
expect(useSession.getState().add.L5).toBe('move judgement to edge')
})
it('renders the assembled document with all five layers', () => {
seedFullAdd()
useSession.getState().setAddLayer('L4', 'fail')
useSession.getState().setAddLayer('L5', 'redesign')
renderPage()
const doc = screen.getByTestId('add-document')
expect(doc).toHaveTextContent('stay safe')
expect(doc).toHaveTextContent('reason')
expect(doc).toHaveTextContent('act')
expect(doc).toHaveTextContent('fail')
expect(doc).toHaveTextContent('redesign')
})
it('exports via window.print', async () => {
const user = userEvent.setup()
const print = vi.fn()
window.print = print
renderPage()
await user.click(screen.getByRole('button', { name: /export pdf/i }))
expect(print).toHaveBeenCalled()
})
it('gates Submit until all five layers have content', async () => {
const user = userEvent.setup()
seedFullAdd()
renderPage()
const submit = screen.getByRole('button', { name: /submit add/i })
expect(submit).toBeDisabled()
await user.type(screen.getByLabelText(/layer 4/i), 'failure')
expect(submit).toBeDisabled()
await user.type(screen.getByLabelText(/layer 5/i), 'redesign')
expect(submit).toBeEnabled()
})
it('records a submission code and completes the phase on submit', async () => {
const user = userEvent.setup()
seedFullAdd()
useSession.getState().setAddLayer('L4', 'failure')
useSession.getState().setAddLayer('L5', 'redesign')
renderPage()
await user.click(screen.getByRole('button', { name: /submit add/i }))
const { submission, phases } = useSession.getState()
expect(submission.code).toMatch(/^KIT-03-/)
expect(submission.submittedAt).not.toBeNull()
expect(phases.add).toBe(true)
expect(screen.getByText(submission.code as string)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /agent submitted/i })).toBeInTheDocument()
})
})
+48 -104
View File
@@ -1,124 +1,68 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { AddLayerForm } from '@/components/AddLayerForm'
import { AddDocument } from '@/components/AddDocument'
import { OpenYourNode } from '@/components/OpenYourNode'
import { useNavigate } from 'react-router-dom'
import { PanelHeading } from '@/components/cockpit/PanelChrome'
import { useSetProceed } from '@/lib/ProceedContext'
import { useSession } from '@/store/session'
import { makeSubmissionCode } from '@/lib/submission'
/**
* Module 3 · Submit — the finish line. The deliverable is the agent itself (the
* makeup/skills you shaped and shipped to the board), so there's no Agent Design
* Document to assemble or gate on: submitting just records the team's entry for
* judging. The advance control lives in the sidebar.
*/
export function AddBuilder() {
const navigate = useNavigate()
const team = useSession((s) => s.team)
const add = useSession((s) => s.add)
const submission = useSession((s) => s.submission)
const setSubmission = useSession((s) => s.setSubmission)
const completePhase = useSession((s) => s.completePhase)
const complete =
add.L1.trim().length > 0 &&
add.L2.trim().length > 0 &&
add.L3.trim().length > 0 &&
add.L4.trim().length > 0 &&
add.L5.trim().length > 0
const onExport = () => window.print()
const onSubmit = () => {
const code = makeSubmissionCode(team, add)
setSubmission({ code, submittedAt: new Date().toISOString() })
completePhase('add')
// best-effort push to the collective is wired in the sync layer (Part B)
}
const submitted = !!submission.code
const onSubmit = () => {
setSubmission({ code: makeSubmissionCode(team, add), submittedAt: new Date().toISOString() })
completePhase('add')
}
useSetProceed(
submitted
? { label: 'Back to start', disabled: false, onClick: () => navigate('/workshop') }
: { label: 'Submit agent', disabled: false, onClick: onSubmit },
)
if (submitted) {
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between print:hidden">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
<section>
<div className="mt-5 rounded-[18px] border border-[var(--green-border-2)] bg-[linear-gradient(180deg,var(--green-bg),var(--surface))] px-12 py-14 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-[var(--green)] text-[32px] text-white">
✓
</div>
<Link to="/workshop/module2" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Module 2
</Link>
</header>
<div className="print:hidden">
<PhaseStrip active="add" />
</div>
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div className="print:hidden">
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 5 of 5 · ~90 min · deadline 19:00
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Module 3 · Harness, Loops &amp; submit</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Finish Layers 4 and 5 — how your node reasons and how it runs over time — review the assembled
Agent Design Document, export a PDF, and submit before the deadline.
<h1 className="mt-6 text-[44px] font-semibold tracking-[-0.02em]">Agent submitted</h1>
<p className="mx-auto mt-3.5 max-w-[460px] text-[18px] leading-[1.5] text-ink-2">
Team <strong className="font-semibold text-ink">{team.name || 'your team'}</strong> — your agent is
in for judging, running the makeup you shaped on the board.
</p>
<div className="mt-3">
<OpenYourNode variant="inline" />
<div className="mt-8">
<button
type="button"
onClick={() => navigate('/workshop')}
className="rounded-[9px] border border-line-2 bg-surface px-[22px] py-2.5 text-[15px] text-ink transition-colors hover:border-blue hover:text-blue-ink"
>
← Back to start
</button>
</div>
</div>
<div className="grid lg:grid-cols-2 gap-6 print:hidden">
<AddLayerForm
layer="L4"
title="ADD · Layer 4 — Harness (where each decision runs)"
description="Which decisions run on the board, which escalate to the cloud — and what still works with no network at all. Describe the degradation path, not just the happy path."
placeholder={
'Routine checks: on-board model, no network needed.\n' +
'Ambiguous or high-consequence calls: escalate to the cloud model.\n\n' +
'Degradation path:\n' +
'• cloud slow or rate-limited → fall back on-board, note reduced confidence\n' +
'• no network at all → keep sensing, logging and safing locally; queue anything that needs escalation\n' +
'• on-board model unavailable → stop actuating, alert, keep recording'
}
/>
<AddLayerForm
layer="L5"
title="ADD · Layer 5 — Loops (cadence, and what happens when a cycle fails)"
description="How often it checks, what it reports by exception — and how the loop behaves when a cycle fails: stale readings, missed ticks, partial data."
placeholder={
'Heartbeat every 30 s; sample the sensor each minute; report only on exception; daily summary.\n\n' +
'When a cycle fails:\n' +
'• missed tick → skip, do not back-fill invented data\n' +
'• partial data → report what is missing, not an average of what is left\n' +
'• N consecutive failures → escalate to a human and stop acting on the readings'
}
/>
</div>
<Card className="print:border-0 print:shadow-none">
<CardHeader className="print:hidden flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Assembled document</CardTitle>
<Button variant="outline" size="sm" onClick={onExport}>Export PDF</Button>
</CardHeader>
<CardContent className="print:p-0">
<AddDocument />
</CardContent>
</Card>
<div className="flex items-center justify-between gap-4 pt-2 print:hidden">
{submitted ? (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Submitted</div>
<div className="font-mono text-sm font-bold text-teal">{submission.code}</div>
</div>
) : (
<span className="text-xs text-muted-foreground">
{complete ? 'All five layers complete — ready to submit.' : 'Complete all five layers to submit.'}
</span>
)}
<Button size="lg" disabled={!complete || submitted} onClick={onSubmit}>
{submitted ? 'Submitted ✓' : 'Submit ADD'}
</Button>
</div>
</section>
</main>
)
}
return (
<section>
<PanelHeading
title="Submit your agent"
intro="You've met your agent, shaped its skills and policies, and wired up its dashboard — submit it for judging from the sidebar when you're ready; your node keeps running the design you shipped."
size={44}
/>
</section>
)
}
+18 -78
View File
@@ -1,15 +1,21 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { EnvSetup } from './EnvSetup'
import { useSession } from '@/store/session'
import { sayHi } from '@/lib/api'
// The page now renders the cockpit dashboard (CockpitRail "agent") inline below
// the copy, with the architecture card able to slide out beside it. Stub the
// heavy dashboard so this unit test focuses on the page shell (copy + gate).
vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()),
sayHi: vi.fn(),
getMode: () => Promise.resolve({ localMode: false }),
}))
vi.mock('@/components/cockpit/CockpitRail', () => ({ CockpitRail: () => null }))
vi.mock('@/components/cockpit/AgentArchitecture', () => ({
ArchitectureProvider: ({ children }: { children: unknown }) => children,
ArchitecturePanel: () => null,
}))
const mockSayHi = vi.mocked(sayHi)
function renderPage() {
return render(
@@ -19,96 +25,30 @@ function renderPage() {
)
}
/** Connect a board with a live node URL — the common precondition. */
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl })
}
describe('EnvSetup — Meet your node', () => {
describe('EnvSetup — Meet your agent', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
mockSayHi.mockReset()
})
it('renders the phase strip set to setup and the heading', () => {
it('renders the heading', () => {
renderPage()
expect(screen.getByTestId('phase-strip')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /meet your node/i })).toBeInTheDocument()
expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="setup"]'),
).toHaveAttribute('data-state', 'active')
expect(screen.getByRole('heading', { name: /meet your agent/i })).toBeInTheDocument()
})
it('prompts to claim a board first when not connected', () => {
it('nudges to connect the board first when not connected', () => {
renderPage()
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
expect(screen.getByText(/connect your board on the previous step/i)).toBeInTheDocument()
})
it('shows the node as Connected with the board url when claimed', () => {
connect('http://192.168.1.7:8080')
renderPage()
const link = screen.getByRole('link', { name: /open your node/i })
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
expect(link).toHaveAttribute('target', '_blank')
expect(screen.getByText(/^connected$/i)).toBeInTheDocument()
})
it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
connect(null)
renderPage()
expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument()
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
})
it('says hi to the agent and shows its reply', async () => {
mockSayHi.mockResolvedValue("Hi! I'm your node — I can read your sensor and drive the matrix.")
it('drops the connect nudge once the board is connected', () => {
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
expect(await screen.findByTestId('node-reply')).toHaveTextContent(/read your sensor/i)
expect(screen.getByRole('button', { name: /your node replied/i })).toBeInTheDocument()
})
it('requires a domain even after the agent replies', async () => {
mockSayHi.mockResolvedValue('hello there')
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
await screen.findByTestId('node-reply')
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
})
it('keeps Proceed gated when a domain is named but the agent has not replied', () => {
connect()
useSession.getState().setDomain('air quality')
renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
})
it('enables Proceed once the agent has replied AND a domain is named', async () => {
mockSayHi.mockResolvedValue('hello there')
connect()
useSession.getState().setDomain('structural stress')
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
await screen.findByTestId('node-reply')
expect(proceed).toBeEnabled()
expect(useSession.getState().stats.calls).toBe(0)
})
it('surfaces an error when the node does not answer', async () => {
mockSayHi.mockRejectedValue(new Error('your node did not answer — is it online?'))
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/did not answer/i)
// the advance button now lives in the sidebar; the nudge disappears when online
expect(screen.queryByText(/connect your board on the previous step/i)).toBeNull()
})
})
+18 -183
View File
@@ -1,205 +1,40 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { OpenYourNode } from '@/components/OpenYourNode'
import { DomainPicker } from '@/components/DomainPicker'
import { useNavigate } from 'react-router-dom'
import { PanelHeading } from '@/components/cockpit/PanelChrome'
import { CockpitRail } from '@/components/cockpit/CockpitRail'
import { ArchitectureProvider, ArchitecturePanel } from '@/components/cockpit/AgentArchitecture'
import { useSetProceed } from '@/lib/ProceedContext'
import { useSession } from '@/store/session'
import { sayHi } from '@/lib/api'
import { cn } from '@/lib/utils'
type HiState = 'idle' | 'running' | 'ok'
export function EnvSetup() {
const navigate = useNavigate()
const teamId = useSession((s) => s.teamId)
const device = useSession((s) => s.device)
const domain = useSession((s) => s.domain)
const completePhase = useSession((s) => s.completePhase)
const [hi, setHi] = useState<HiState>('idle')
const [reply, setReply] = useState('')
const [hiError, setHiError] = useState<string | null>(null)
const saidHi = async () => {
if (hi === 'running') return
setHi('running')
setHiError(null)
setReply('')
try {
// Chat with the agent on the team's own board; a reply means it's live.
const r = await sayHi(teamId, 'cloud')
setReply(r || '(your node replied)')
setHi('ok')
} catch (e) {
setHiError(e instanceof Error ? e.message : 'Could not reach your node.')
setHi('idle')
}
}
const ready = device.connected && hi === 'ok' && domain.trim().length > 0
const ready = device.connected
const onProceed = () => {
completePhase('setup')
navigate('/workshop/module1')
}
useSetProceed({ label: 'Skills & Policies →', disabled: !ready, onClick: onProceed })
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/workshop" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Team registration
</Link>
</header>
<section>
<PanelHeading title="Meet your agent" size={34} />
<PhaseStrip active="setup" />
<section className="px-8 py-10 max-w-3xl mx-auto space-y-6">
<div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 2 of 5 · ~15 min
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Meet your node</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Your board is a node running its own agent. Open it, say hi, name the domain it&rsquo;s
for — then you&rsquo;re clear for Module 1.
</p>
{/* the dashboard, below the title — its architecture card slides out to the RIGHT */}
<ArchitectureProvider>
<div className="relative mt-5 w-full max-w-[620px]">
<ArchitecturePanel />
<CockpitRail variant="agent" />
</div>
</ArchitectureProvider>
{/* 1 — Open your node (hero) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Open your node</CardTitle>
</CardHeader>
<CardContent>
<OpenYourNode variant="hero" />
</CardContent>
</Card>
{/* 2 — Say hi to your agent */}
<Card>
<CardHeader>
<CardTitle className="text-base">Say hi to your agent</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Say hi to the agent running on your board. When it replies, your node is live and
listening — and you&rsquo;re clear to name its domain.
</p>
{/* the exchange */}
{(hi !== 'idle' || reply) && (
<div className="space-y-2" data-testid="say-hi-chat">
<div className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[80%]">Hi 👋</span>
</div>
{hi === 'running' && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
your node is thinking…
</div>
)}
{reply && (
<div className="flex justify-start">
<span className="rounded-lg border border-teal/40 bg-teal/5 px-3 py-1.5 text-sm max-w-[80%]" data-testid="node-reply">
{reply}
</span>
</div>
)}
</div>
)}
<Button
variant={hi === 'ok' ? 'outline' : 'default'}
className="w-full justify-start"
disabled={!device.connected || hi === 'running'}
onClick={saidHi}
>
<span
className={cn(
'w-2 h-2 rounded-full mr-3',
hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)}
/>
{hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
</Button>
{hiError && (
<p role="alert" className="text-xs text-red-500 leading-relaxed">{hiError}</p>
)}
{!device.connected && (
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Bind your board in team registration first.
{!ready && (
<p className="mt-6 font-mono text-[12px] tracking-[0.04em] text-ink-3">
Connect your board on the previous step to bring your agent online.
</p>
)}
</CardContent>
</Card>
{/* 3 — Pick your domain */}
<Card>
<CardHeader>
<CardTitle className="text-base">Pick your domain</CardTitle>
</CardHeader>
<CardContent>
<DomainPicker />
</CardContent>
</Card>
{/* 4 — Extra channels (optional) */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Extra channels <span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">optional</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Your dashboard is the main way in, but you can reach your node other ways too:
</p>
<ul className="space-y-2">
<li>
<span className="font-medium text-foreground">Telegram</span> — make a bot with{' '}
<span className="font-mono text-xs">@BotFather</span>, then paste its token in the node
dashboard → Config → channels.
</li>
<li>
<span className="font-medium text-foreground">Voice</span> — use the mic button in the
node dashboard to talk to it directly.
</li>
</ul>
</CardContent>
</Card>
{/* 5 — Lock it down (policy moment) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lock it down</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Your board boots <span className="font-medium text-foreground">open</span> so setup is
frictionless — anyone on the LAN can reach it right now. That&rsquo;s your first{' '}
<span className="font-medium text-foreground">policy</span> decision: when setup is done,
harden it so only your group can talk to it.
</p>
<p>
Run <span className="font-mono text-xs">zeroclaw-lockdown.sh</span> on the board — it mints
a pair code your group uses to reconnect. Leave it open for now; you&rsquo;ll revisit this
once you&rsquo;ve designed the agent&rsquo;s policies.
</p>
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to Module 1 →
</Button>
</div>
</section>
</main>
)
}
+13 -15
View File
@@ -19,31 +19,29 @@ describe('Landing', () => {
expect(screen.getByText(/July 27, 2026/i)).toBeInTheDocument()
})
it('exposes the four PRD success-metric stats', () => {
it('shows the presenter contact details', () => {
renderLanding()
expect(screen.getByText(/14:00 – 19:00/i)).toBeInTheDocument()
expect(screen.getByText(/15 teams/i)).toBeInTheDocument()
expect(screen.getAllByText(/Arduino Uno Q/i).length).toBeGreaterThan(0)
expect(screen.getByRole('link', { name: /^redclaw\.dev$/i })).toHaveAttribute('href', 'https://redclaw.dev')
expect(screen.getByRole('link', { name: /osobh@redclaw\.dev/i })).toHaveAttribute(
'href',
'mailto:[email protected]',
)
})
it('has a primary CTA linking to /workshop', () => {
renderLanding()
const ctas = screen.getAllByRole('link', { name: /enter workshop/i })
const ctas = screen.getAllByRole('link', { name: /start the workshop/i })
expect(ctas.length).toBeGreaterThan(0)
ctas.forEach((cta) => expect(cta).toHaveAttribute('href', '/workshop'))
})
it('exposes staff sign-in entrances for judge and instructor', () => {
it('drops the class-event chrome for the local single-team demo', () => {
renderLanding()
expect(screen.getByRole('link', { name: /judge/i })).toHaveAttribute('href', '/judge')
expect(screen.getByRole('link', { name: /instructor/i })).toHaveAttribute('href', '/admin')
})
it('lists the programme timeline with at least 5 phases', () => {
renderLanding()
const programme = screen.getByTestId('programme')
const rows = programme.querySelectorAll('[data-programme-row]')
expect(rows.length).toBeGreaterThanOrEqual(5)
// no staff sign-in, no lecture links, no programme timeline
expect(screen.queryByRole('link', { name: /judge/i })).toBeNull()
expect(screen.queryByRole('link', { name: /instructor/i })).toBeNull()
expect(screen.queryByRole('link', { name: /lecture/i })).toBeNull()
expect(screen.queryByTestId('programme')).toBeNull()
})
it('renders the speaker card with Omar Sobh', () => {
+20 -158
View File
@@ -1,37 +1,7 @@
import { Link } from 'react-router-dom'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ProgrammeRow {
time: string
title: string
desc: string
tags?: string[]
}
const PROGRAMME: ProgrammeRow[] = [
{ time: '10:45', title: 'Lecture · Agentic design thinking', desc: 'Separate morning session — the five layers, and designing for failure.', tags: ['lecture'] },
{ time: '14:00', title: 'Arrival & registration', desc: 'Boards backed up and reflashed for the workshop while you register.', tags: ['setup'] },
{ time: '14:25', title: 'Meet your node', desc: 'The board you already know — now carrying an agent that can drive your devices.', tags: ['setup'] },
{ time: '14:45', title: 'Module 1 · Domain & events', desc: 'Your sensors, your data, the events that matter — Layer 1.', tags: ['build'] },
{ time: '16:10', title: 'Module 2 · Skills & policies', desc: 'Drive a real sensor, enumerate the failure states, set the actuation gate — Layers 2 + 3.', tags: ['build'] },
{ time: '17:40', title: 'Module 3 · Harness, loops & submit', desc: 'How it degrades, how often it runs, then submit — Layers 4 + 5.', tags: ['add'] },
{ time: '19:00', title: 'Judging & award', desc: 'Panel reviews the Agent Design Documents; RedClaw Systems award announced.', tags: ['judge'] },
]
const STACK = [
{ icon: '⚙', name: 'Rust on the Uno Q', desc: 'ZeroClaw runs on the Uno Q’s quad-core Linux side and self-flashes its on-board STM32 MCU.' },
{ icon: '⌬', name: 'Claude on the edge', desc: 'Each node reasons with Claude via a Max token, with an on-board Qwen model as an offline fallback.' },
{ icon: '⚡', name: 'Talk to your node', desc: 'Load expert skills, then converse with the board through its own dashboard, Telegram, or voice.' },
]
const PREREQS = [
'Laptop with Chrome or Edge (to reach your board)',
'USB-C data cable (provided in kit)',
'Basic Rust familiarity helpful but not required',
'Hands-on attitude — you will flash hardware today',
]
import { Card, CardContent } from '@/components/ui/card'
export function Landing() {
return (
@@ -42,30 +12,8 @@ export function Landing() {
<span className="text-muted-foreground"> · Workshop</span>
</div>
<div className="flex items-center gap-3">
<Link to="/lecture" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Lecture
</Link>
<details className="relative group [&_summary::-webkit-details-marker]:hidden">
<summary className="list-none cursor-pointer select-none font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Staff sign-in ▾
</summary>
<div className="absolute right-0 mt-2 w-40 rounded-md border border-border bg-background shadow-md py-1 z-50">
<Link
to="/judge"
className="block px-3 py-2 font-mono text-[11px] text-muted-foreground hover:text-foreground hover:bg-secondary/60 tracking-widest uppercase"
>
Judge
</Link>
<Link
to="/admin"
className="block px-3 py-2 font-mono text-[11px] text-muted-foreground hover:text-foreground hover:bg-secondary/60 tracking-widest uppercase"
>
Instructor
</Link>
</div>
</details>
<Button asChild size="sm">
<Link to="/workshop">Enter workshop →</Link>
<Link to="/workshop">Start the workshop →</Link>
</Button>
</div>
</header>
@@ -78,7 +26,7 @@ export function Landing() {
<h1 className="text-5xl md:text-6xl font-bold tracking-tight leading-[1.05]">
Design a domain node
<br />
<span className="text-primary">a Claude agent on the edge</span>
<span className="text-primary">an APESS agent on the edge</span>
</h1>
<p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
Your Uno Q already senses. Today it gets an agent — one you talk to, one that drives your devices, and
@@ -87,51 +35,8 @@ export function Landing() {
</p>
<div className="flex flex-wrap gap-3 justify-center pt-4">
<Button asChild size="lg">
<Link to="/workshop">Enter workshop →</Link>
<Link to="/workshop">Start the workshop →</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link to="/lecture">Read the lecture</Link>
</Button>
</div>
</div>
<div className="max-w-4xl mx-auto mt-16 grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden text-center">
{[
['Hackathon', '14:00 – 19:00'],
['Teams', '15 teams'],
['Per team', '3–5 students'],
['Hardware', 'Arduino Uno Q · 4 GB'],
].map(([k, v]) => (
<div key={k} className="bg-background p-4 space-y-1">
<div className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground">{k}</div>
<div className="text-sm font-semibold">{v}</div>
</div>
))}
</div>
</section>
<section className="px-8 py-16 border-b border-border">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Programme</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Lecture at 10:45 · build from 14:00</h2>
</div>
<div data-testid="programme" className="border border-border rounded-lg overflow-hidden bg-card divide-y divide-border">
{PROGRAMME.map((row) => (
<div key={row.time} data-programme-row className="grid grid-cols-[80px_1fr_auto] gap-6 px-6 py-4 items-start hover:bg-secondary/40 transition">
<div className="font-mono text-xs text-muted-foreground pt-0.5">{row.time}</div>
<div>
<div className="text-sm font-semibold">{row.title}</div>
<div className="text-xs text-muted-foreground mt-1 leading-relaxed">{row.desc}</div>
</div>
<div className="flex gap-1.5 flex-wrap justify-end">
{row.tags?.map((t) => (
<Badge key={t} variant="outline" className="font-mono text-[9px] uppercase tracking-wider">
{t}
</Badge>
))}
</div>
</div>
))}
</div>
</div>
</section>
@@ -155,6 +60,22 @@ export function Landing() {
Builds AI agents that live at the edge — from on-device LLM runtimes to multi-agent fleets.
Maintains ZeroClaw, EdgeHDF5, and RustyHDF5. Previously: hardware + ML at scale.
</p>
<div className="space-y-1 font-mono text-xs">
<a
href="https://redclaw.dev"
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
redclaw.dev
</a>
<div className="text-muted-foreground">
Email:{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
osobh@redclaw.dev
</a>
</div>
</div>
<div className="flex gap-1.5 flex-wrap">
{['Rust', 'Edge AI', 'Agentic systems', 'HDF5'].map((c) => (
<Badge key={c} variant="outline" className="font-mono text-[9px] uppercase tracking-wider">
@@ -168,65 +89,6 @@ export function Landing() {
</div>
</section>
<section className="px-8 py-16 border-b border-border">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Stack</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">What runs where</h2>
</div>
<div className="grid md:grid-cols-3 gap-4">
{STACK.map((s) => (
<Card key={s.name}>
<CardHeader className="pb-3">
<div className="w-9 h-9 rounded-md bg-primary/10 text-primary flex items-center justify-center text-lg">
{s.icon}
</div>
<CardTitle className="text-base">{s.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground leading-relaxed">{s.desc}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
<section className="px-8 py-16 border-b border-border bg-secondary/30">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Prerequisites</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Bring this · we provide the rest</h2>
</div>
<div className="grid md:grid-cols-2 gap-3">
{PREREQS.map((p, i) => (
<Card key={p} className="p-4 flex gap-3">
<div className="font-mono text-xs font-bold text-primary w-5 shrink-0">{String(i + 1).padStart(2, '0')}</div>
<div className="text-sm text-muted-foreground leading-relaxed">{p}</div>
</Card>
))}
</div>
</div>
</section>
<section className="px-8 py-16">
<div className="max-w-4xl mx-auto">
<Card className="bg-primary text-primary-foreground border-primary">
<CardContent className="p-10 flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<div className="text-xl md:text-2xl font-bold tracking-tight">Ready when you are.</div>
<div className="font-mono text-[10px] uppercase tracking-widest opacity-70 mt-2">
Five screens · one Agent Design Document · 19:00 deadline
</div>
</div>
<Button asChild size="lg" variant="secondary">
<Link to="/workshop">Start Module 1 →</Link>
</Button>
</CardContent>
</Card>
</div>
</section>
<footer className="px-8 py-6 border-t border-border font-mono text-[10px] text-muted-foreground flex justify-between items-center">
<span>RedClaw Systems LLC · Los Gatos, CA</span>
<span>apess.redclaw.dev</span>

Some files were not shown because too many files have changed in this diff Show More