Compare commits

..
68 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
Omar SobhandClaude Opus 4.8 49d75111f1 Merge feat/workshop-failure-first-alignment: edge + control-plane federation
Board hwId identity (Phase 1), reporter sidecar + central ingest
(Phase 2), and the central fleet dashboard (Phase 3), plus the earlier
workshop failure-first alignment + refine features.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:28:53 -07:00
Omar SobhandClaude Opus 4.8 45cc3a1f85 feat(fleet): central fleet dashboard — instances → teams grouped by site
Phase 3 of the edge + control-plane architecture (frontend). The
instructor dashboard becomes fleet-aware: when local instances have
phoned home (central mode), it groups teams under the instance that
reported them, each with an online/offline dot (from heartbeat
last-seen) + team count; otherwise it renders the existing flat grid
unchanged (single-fleet deploys are unaffected).

- src/types.ts: mirror the api — `site?` on TeamSnapshot/SubmissionDTO,
  new InstanceDTO, `instance:update` event + `instances` on snapshot.
- api.ts: getInstances(code) (GET /instances; [] on single-fleet).
- useCollective: instances in state + reducer (snapshot seeds, instance:
  update upserts, an instance-less snapshot preserves known instances),
  polled in the REST seed without dropping the team seed on failure.
- Admin: FleetView (grouped) vs TeamGrid (flat), an Instances stat
  (live/total). Shared TeamGrid extracted from the old inline grid.

Judging already works centrally (submission ids are site-namespaced by
the reporter); deeper site-grouping in Judge + Landing-as-distribution
are deferred (plan's "refined as we go").

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:24:22 -07:00
Omar SobhandClaude Opus 4.8 5ef14655b9 feat(federation): reporter sidecar + central ingest (edge → control plane)
Phase 2 of the edge + control-plane architecture. A per-team local stack
can now mirror its state UP to a central instance so judges get a fleet
view — outbound-only, so it works from behind the room NAT.

Central ingest (same api/, central-mode):
- POST /instances/register + /instances/:id/heartbeat (fleet-secret
  gated) + an `instances` table and GET /instances (admin) fleet read.
- PUT /teams/:id and POST /submissions accept an optional `site` tag;
  `site` column added to teams + submissions (grouping/filtering).
- WS snapshot now carries instances; instance:update broadcast added.

Reporter sidecar (deploy/lan/reporter/, opt-in `federated` compose
profile): subscribes to the local WS feed and replays team/submission
writes up to central, namespaced by SITE_ID (ids never collide) and
site-tagged. Registers + heartbeats; failed writes queue in an in-memory
outbox and backfill on reconnect. Proven end-to-end (local→reporter→
central) before commit.

Also fixes a latent bug: listSubmissions selected snake_case columns but
mapped camelCase, so GET /submissions summaries were missing teamId/
teamName/submittedAt. Aliased the columns; added a regression assertion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:18:03 -07:00
Omar SobhandClaude Opus 4.8 60416cb8c7 feat(api): anchor board identity on durable hwId + persist the registry
The board registry keyed on kitId, so a board that changed IP, was
re-provisioned under a new label, or came up on a wiped laptop looked
like a brand-new board — breaking claim resume and auto-heal.

Key the registry on hwId (WiFi MAC + eMMC serial, survives OS reflash;
machine-id does not). kitId is now a display label only. announce()
takes hwId and upserts by it, preserving claimedBy across IP/label
changes. /nodes/self-register accepts hwId (falls back to kitId for
older nodes). Persist boards in a new sqlite `boards` table keyed by
hwId and seed the in-memory registry from it on boot, so recognition
survives an API restart.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 23:08:50 -07:00
Omar SobhandClaude Opus 4.8 d71899520e fix(provision): remove legacy @reboot supervisor cron (App Lab owns boot)
A real power-cycle exposed a boot race: the old '@reboot zeroclaw-supervisor.sh' cron
started a host daemon that grabbed :8080 before the App Lab app, so the app's container
failed to bind and exited ('Failed to start default app'). The App Lab app + 'properties
set default' is the boot mechanism now, so the provisioner strips the cron and kills any
running supervisor. Verified the app then comes up clean on :8080.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 20:18:32 -07:00
Omar SobhandClaude Opus 4.8 410e5aba68 fix(provision): carry hardware skills into the node app (skill_bundles)
Copy shared/skills into the app so the [skill_bundles.unoq] bundle loads all 12 Uno Q
skills onto every agent — agent now knows the board + how to drive its devices. Fixes
the cp-nesting gotcha (copy skills/ directly, not the parent). Verified: dashboard agent
lists led-matrix/uno-q-hardware/flashing/sketch-patterns/modulino/bridge/… as loaded.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 18:54:38 -07:00
Omar SobhandClaude Opus 4.8 bedb1b4adc fix(provision): carry web-dist into the app + repoint web_dist_dir; drop stray tag
The gateway dashboard (:8080/) 503'd because web_dist_dir pointed at a host path
absent in the App Lab container. Provisioner now copies web-dist into /app and
repoints web_dist_dir=/app/web-dist so the dashboard serves. Also removed a stray
</content> line. Verified: :8080/ -> 200 (title ZeroClaw), whole node one app.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 18:17:30 -07:00
Omar SobhandClaude Opus 4.8 40bee45842 feat(node): self-register derives node url from source IP; App Lab provisioner
A containerised App Lab node can't see its host LAN IP, but its self-register POST is
SNAT'd to the host, so /nodes/self-register now derives http://<source-ip>:<port> when
url is omitted (explicit url still wins; token defaults to open-lan). Adds
deploy/uno-q/provision-node-app.sh to install the ZeroClaw Node app + carry in its
runtime bits. Tests updated + derived-url coverage (64 pass).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 13:07:32 -07:00
Omar SobhandClaude Opus 4.8 8001e100c3 feat(refine): AI Refine on domain (Phase 2) + wand on ADD layers
Phase 2 'Pick your domain' gets a Refine button that asks the team's node (cloud
sonnet) to draft all four design dimensions from the typed domain — Skills/Policies/
Harness/Loops → ADD layers L2-L5. Each card generates behind the scenes, turns green
when ready, and opens a modal with its draft (failure-first framed prompts). Drafts
land in the session store for later modules.

AddLayerForm (incl. Module 1's L1 Domain & events) gets a 🪄 Refine wand that reformats
+ structures the author's notes in place (meaning preserved) for submission.

Both reuse a new askNode() over the blocking say-hi/promptAndWait path. New reusable
Modal. Proven against the board: Skills draft for 'stress fractures' in 5.6s. Tests +196.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 10:16:18 -07:00
Omar SobhandClaude Opus 4.8 9d44931752 feat(uno-q): agent-mode.sh — flip all agents cloud/offline
Workshop reliability: 'cloud' sets EVERY agent to anthropic.max (claude-sonnet-5) with
full tools (risk_profile=default) + full context (runtime=unoq) so nothing feels broken
and the on-board fallback is out of the way. 'offline' flips all to llamacpp.local with
lean tools (demo) + lean context (offline) so the 0.5B is usable — the fallback you
enable on purpose. Rewrites every [agents.*] block, restarts the daemon (token preserved
via the supervisor env), and verifies. Proven: default/sense/local all now sonnet +
matrix_pattern fires.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 09:58:05 -07:00
Omar SobhandClaude Opus 4.8 cefddb245c feat(phase2): real say-hi chat + Connected node state on Meet-your-node
- Open your node: shows a 'Connected' badge (+ board name) once the board is claimed.
- Say hi: replaces the liveness-poll self-test with an actual round-trip to the
  agent on the team's own board — sends a greeting, shows the agent's reply, and
  only unlocks once it answers. New blocking /nodes/:teamId/say-hi route (greeting
  never flashes, so no hang risk) + bridge.sayHi + client sayHi(). The /prompt route
  stays fire-and-forget for the flash path.
- Pick your domain already captures to the store and syncs to the server DB via
  useCollectiveSync -> pushTeam, available to later steps/judging.
- Proceed gates on connected + agent-replied + domain. Tests updated + say-hi
  coverage (route + page).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 09:26:50 -07:00
Omar SobhandClaude Opus 4.8 fc5f66021f feat(reg): correct Phase 1 for pre-deployed devices + matrix-code binding
Teams already have their boards, so drop the kit pickup + sticker claim. New flow:
run the setup script on your board -> it self-registers and scrolls a code on its
LED matrix -> enter that code to bind the node to your team.

- api: BoardRegistry.claimByCode() binds the unique board whose claimCode matches,
  no kit needed; /claim accepts code-first ({teamId, code[, members]}) and keeps the
  legacy kit path. Snapshot/response use the resolved board's kitId.
- client: ClaimInput.kit optional, carries members.
- BoardClaim: setup-script command + copy, code from the matrix, 'Bind board'.
- TeamRegistration: remove KitSelector + QR-kit preselect; card is now 'Your board'.
- Tests updated + code-first coverage (registry + HTTP).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 06:18:09 -07:00
Omar SobhandClaude Opus 4.8 1b31f6db1b feat(reg): inline MemberFields (+ add-row) replacing the chip input
Phase 1 members now entered as inline text rows with a '+ Add member' affordance and
per-row remove, per the pre-deployed-devices onboarding rework. Parent store still
receives only trimmed, non-empty names. Caps at 5. MemberChips retained (unused) for
now. Tests updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 06:08:28 -07:00
Omar SobhandClaude Opus 4.8 1642fbce39 docs(demo): all three demo secrets now vaulted in Infisical
ANTHROPIC_OAUTH_TOKEN + APESS_NODE_TOKEN added to cloud-providers/prod alongside
ELEVENLABS_API_KEY. Runbook secrets section is now a single Infisical pull for all
three (APESS_NODE_TOKEN vault key -> NODE_TOKEN env). Verified end-to-end.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 05:12:34 -07:00
Omar SobhandClaude Opus 4.8 eb8c41b14b docs(demo): add DEMO-RUNBOOK.md; ElevenLabs key sourced from Infisical
Full demo-day runbook — secrets, pre-flight (recover.sh + voice proxy), the three
acts (web/Telegram/voice), mid-demo recovery, and gotchas. ELEVENLABS_API_KEY now
lives in Infisical on icarus (project cloud-providers, env prod, alongside the other
provider keys); runbook pulls it via the macbook-admin machine identity. Cloud token
and node token stay manual env exports for now.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 05:06:07 -07:00
Omar SobhandClaude Opus 4.8 413230255e feat(voice): ElevenLabs TTS in serve.py (server-side, key env-only)
serve.py gains /tts (ElevenLabs synthesis) + /config (advertises tts mode). Client
plays the returned MP3 when ElevenLabs is on, else falls back to browser speechSynthesis;
barge-in stops in-flight audio. Default voice Sarah (free-tier usable); key read from
ELEVENLABS_API_KEY env, never committed. README documents the free-tier library-voice
402 gotcha. Verified: 200 audio/mpeg, 38KB MP3.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:58:54 -07:00
Omar SobhandClaude Opus 4.8 3fef8c4d23 feat(uno-q): one-command demo-node recovery after USB drop
recover.sh: re-tunnels, relaunches the supervisor with the cloud token in its
environment (env-only, read from ANTHROPIC_OAUTH_TOKEN — never on disk), starts the
matrix bridge app only if down, preserves a running llama to skip cold reload, and
verifies end-to-end (llama/daemon/bridge health + demo agent on claude-sonnet-5 +
matrix_pattern fires). Turns a mid-demo disconnect into a ~30s fix. Tokens are read
from the env, so the script carries no secrets.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:51:43 -07:00
Omar SobhandClaude Opus 4.8 b09068b60c feat(voice): same-origin proxy client reusing the reliable /webhook path
The /ws/chat path builds a fresh Agent that omits the dynamically-registered
peripheral tools (matrix_pattern), so the model improvises with shell/read and the
matrix never changes. Switch the voice client to serve.py, which serves the page and
proxies POST /webhook same-origin to the node gateway (bearer token server-side).
Browser does STT+TTS (Web Speech API, no keys); the agent runs on cloud sonnet and
fires matrix_pattern reliably. Validated end-to-end server-side (checker in 6.2s).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:43:24 -07:00
Omar SobhandClaude Opus 4.8 1c762e51ba feat(voice): browser voice client for talking to the on-board agent
Single-file client: browser-side STT + TTS (Web Speech API, no keys), drives the
lean demo agent over /ws/chat (?agent=demo, no auth on the workshop board) to change
the LED-matrix animation by voice. Push-to-talk, live transcript, spoken reply, tool
activity display. README covers the localhost-serve secure-context requirement and the
ElevenLabs/voice-duplex upgrade path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 21:54:10 -07:00
Omar SobhandClaude Opus 4.8 62e4a3b688 docs(deck): revision guide reconciling the lecture deck to shipped reality
The deck predates v0.8.3, the network-node pivot, and the flash
pathology. Capture what breaks against the app/hardware (stale ADD
layer names, pre-correction schedule, Web-Serial connect flow), the
narrowed flow, and slide-by-slide target copy pinned to addLayers.ts
and Landing.tsx so deck and app cannot re-drift.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 15:28:17 -07:00
Omar SobhandClaude Opus 4.8 37a54521e8 docs(uno-q): record the offline capability boundary
Measured on the board 2026-07-20. Offline is limited by capability, not only
by speed:

  simple tool call (i2cdetect, 708 tok)  -> works, 20s, real answer
  write+compile+flash a sketch (1997 tok) -> never completed in 450s, two
                                             identical requests, no tool call

So with no network the node can sense and decide, but cannot author new
code - that needs the cloud model. This is the concrete degradation boundary
students are asked to state in ADD Layer 4: what survives an outage is the
loop over already-flashed firmware, not writing new firmware.

Also corrects the cold-load figure. The documented 3-5 minutes was measured
against the 1.1GB coder model; the 409MB qwen.gguf we standardised on came
up healthy in ~5s from a cold boot.

Notes that BuildFlash routes to the cloud agent, so the student build/flash
exercise does not sit behind this boundary.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 09:09:48 -07:00
Omar SobhandClaude Opus 4.8 e27fad46e2 fix(workshop): starter prompts must be imperative, not interrogative
Measured on the board 2026-07-20. Same agent, same 708-token prompt,
phrasing the only variable:

  "List the I2C devices on the bus."      -> tool call, answered in 20s
  "What sensors can you find on the bus?" -> no tool call at all, >200s

The first starter prompt shipped as the interrogative form. Teams typing a
question would have watched their board hang for minutes and concluded the
agent was broken. Now imperative, with the measurement recorded next to the
list so nobody "improves" it back into a question.

Also surfaces the asymmetry to students inline: it is a genuine failure mode
of small on-device models, and belongs in their Layer 3 rather than being
hidden.

Separately confirmed the agent does not hallucinate hardware: asked to list
I2C devices on a board with an empty bus, it ran the tool and reported the
bus numbers rather than inventing a sensor.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 08:45:03 -07:00
Omar SobhandClaude Opus 4.8 32750b0d9d fix(admin): show real node tallies on team cards, not dead counters
TeamCard displayed team.stats.{calls,nominal,anomalous,critical}, but
nothing has called recordEvent since the simulator was removed, so every
card read "0 · 0 · 0 · 0" - in the pre-pivot classifier vocabulary.

Admin already had real per-team tallies from useCollective.counts (derived
from the node activity stream, and already used for totalFlashes); they
just were not passed down. Cards now show agent runs / flashes / errors,
with errors only rendered when non-zero.

Same root cause as the AddDocument telemetry removed in 5a5810a.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 04:06:30 -07:00
Omar SobhandClaude Opus 4.8 5a5810a1c8 feat(workshop): align to the real programme; failure-first framing
Reviewed the official APESS 2026 programme against the app as shipped.
The structure held up; several published facts and one scoring bug did not.

Fixes a real scoring defect: AddReview (what judges read) still carried
pre-pivot layer titles - "Reasoning policy", "Action contract", "Failure
modes", "AI-native redesign" - so judges scored "Skills" under the heading
"Reasoning policy". Root cause was two duplicated title lists that drifted
after the domain-node reshape, so both surfaces now render from a single
ADD_LAYERS constant and cannot diverge again.

Also removes dead classifier telemetry from the graded artifact: the ADD
printed "N frames - N nominal - N anomalous - N critical", but nothing has
called recordEvent since the simulator was deleted, so those counters were
permanently zero - and the vocabulary predates the pivot.

Corrects the schedule. The app advertised the lecture at 14:00, inside the
hackathon block; the programme puts it at 10:45-12:15 as a separate morning
session, with the hackathon 14:00-19:00. Moving it out recovers an hour,
which the new timings spend on the sensor work rather than setup.

Reframes the session around designing for failure, per the workshop premise
and the school's "proactive resilient systems" theme:

  L3 becomes Policies & failure - which way each failure fails, with the
     governing rule that a fail-safe must never quietly report "nominal"
  L4 becomes where each decision runs - the degradation path, cloud to
     on-board to fully offline, not just the happy path
  L5 gains what the loop does when a cycle fails - stale reads, missed
     ticks, partial data

Students arrive having spent a week on their own sensor work with these
boards, so the hands-on now points the agent at hardware they already
wired (discover the bus, read it, act on a threshold) instead of only
scrolling text, and the framing invites the domain they are already
measuring. The domain stays free-text.

Adds backup-uno-q.sh: boards are reflashed on the day and a week of
student work is irreplaceable. Denylist rather than allowlist, because we
cannot know where a given team put their data; verifies the archive is
readable and non-trivial before reporting success. Deliberately keeps the
App Lab examples dir - stock, but exactly what someone would edit in place.
Verified end to end: byte-identical restore of a real sketch.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-19 17:26:04 -07:00
146 changed files with 9613 additions and 1382 deletions
+7
View File
@@ -32,3 +32,10 @@ deploy/workshop-llm/.env
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Distributable artifacts (baked token) — never commit
deploy/download/*.zip
deploy/download/*.tar*
# Local dev copy of the onboarding zip (baked token)
public/download/
+54 -1
View File
@@ -99,7 +99,9 @@ describe('collective API', () => {
it('lists submission summaries with a scored flag', async () => { it('lists submission summaries with a scored flag', async () => {
await request(app).post('/submissions').send({ teamId: 't1', code: 'c', add: fullAdd }) await request(app).post('/submissions').send({ teamId: 't1', code: 'c', add: fullAdd })
const before = await request(app).get('/submissions').set('X-Access-Code', JUDGE) const before = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
expect(before.body[0].scored).toBe(false) // the summary must carry the identifying fields, not just `scored`
expect(before.body[0]).toMatchObject({ teamId: 't1', scored: false })
expect(typeof before.body[0].submittedAt).toBe('string')
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 't1', total: 8 }) await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 't1', total: 8 })
const after = await request(app).get('/submissions').set('X-Access-Code', JUDGE) const after = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
expect(after.body[0].scored).toBe(true) expect(after.body[0].scored).toBe(true)
@@ -121,4 +123,55 @@ describe('collective API', () => {
expect(events.some((e) => e.type === 'score:new')).toBe(true) expect(events.some((e) => e.type === 'score:new')).toBe(true)
}) })
}) })
describe('federation (central-mode)', () => {
const FLEET = 'fleet-secret'
let central: ReturnType<typeof createApp>
let cEvents: WsEvent[]
beforeEach(() => {
cEvents = []
central = createApp({
store: openStore(':memory:'),
broadcast: (e) => cEvents.push(e),
adminCode: ADMIN,
judgeCode: JUDGE,
fleetSecret: FLEET,
now: () => '2026-07-27T14:00:00.000Z',
})
})
it('registers an instance (fleet-secret gated) and broadcasts instance:update', async () => {
await request(central).post('/instances/register').send({ id: 'site-a', name: 'Team A laptop' }).expect(401)
const res = await request(central)
.post('/instances/register')
.set('x-fleet-secret', FLEET)
.send({ id: 'site-a', name: 'Team A laptop' })
.expect(201)
expect(res.body).toMatchObject({ id: 'site-a', name: 'Team A laptop', lastSeen: '2026-07-27T14:00:00.000Z' })
expect(cEvents.some((e) => e.type === 'instance:update')).toBe(true)
const list = await request(central).get('/instances').set('X-Access-Code', ADMIN).expect(200)
expect(list.body).toEqual([{ id: 'site-a', name: 'Team A laptop', lastSeen: '2026-07-27T14:00:00.000Z' }])
})
it('heartbeat upserts the same instance by id', async () => {
await request(central).post('/instances/register').set('x-fleet-secret', FLEET).send({ id: 'site-a' })
await request(central).post('/instances/site-a/heartbeat').set('x-fleet-secret', FLEET).send({}).expect(201)
const list = await request(central).get('/instances').set('X-Access-Code', ADMIN)
expect(list.body).toHaveLength(1) // still one instance, not duplicated
expect(list.body[0]).toMatchObject({ id: 'site-a', name: 'site-a' }) // name falls back to id
})
it('ingests site-tagged team + submission writes so central can group by site', async () => {
await request(central).put('/teams/site-a:team-1').send(team('team-1', { site: 'site-a' })).expect(204)
await request(central)
.post('/submissions')
.send({ teamId: 'site-a:team-1', code: 'c', add: fullAdd, site: 'site-a' })
.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' })
})
})
}) })
+195 -13
View File
@@ -4,6 +4,7 @@ import type { Store } from './db'
import { requireCode, requireAnyCode, matches } from './auth' import { requireCode, requireAnyCode, matches } from './auth'
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types' import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
import type { NodeBridge } from './nodes' import type { NodeBridge } from './nodes'
import { readMatrixFrame } from './nodes'
import type { BoardRegistry } from './claim' import type { BoardRegistry } from './claim'
export interface AppOptions { export interface AppOptions {
@@ -19,6 +20,9 @@ export interface AppOptions {
boards?: BoardRegistry boards?: BoardRegistry
/** Shared fleet secret boards present when self-registering. */ /** Shared fleet secret boards present when self-registering. */
fleetSecret?: string 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 } 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). */ /** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */
export function createApp(opts: AppOptions): Express { 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 now = opts.now ?? (() => new Date().toISOString())
const app = express() const app = express()
app.use(cors({ origin: opts.corsOrigin ?? true })) app.use(cors({ origin: opts.corsOrigin ?? true }))
@@ -36,6 +40,12 @@ export function createApp(opts: AppOptions): Express {
res.type('text/plain').send('ok') 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 ------------------------------------------- // --- public participant sync -------------------------------------------
app.put('/teams/:id', (req, res) => { app.put('/teams/:id', (req, res) => {
const b = req.body ?? {} const b = req.body ?? {}
@@ -49,6 +59,7 @@ export function createApp(opts: AppOptions): Express {
stats: { ...emptyStats, ...(b.stats ?? {}) }, stats: { ...emptyStats, ...(b.stats ?? {}) },
deviceConnected: !!b.deviceConnected, deviceConnected: !!b.deviceConnected,
updatedAt: typeof b.updatedAt === 'string' ? b.updatedAt : now(), updatedAt: typeof b.updatedAt === 'string' ? b.updatedAt : now(),
site: typeof b.site === 'string' ? b.site : '',
} }
store.upsertTeam(team) store.upsertTeam(team)
broadcast({ type: 'team:update', team }) broadcast({ type: 'team:update', team })
@@ -66,6 +77,7 @@ export function createApp(opts: AppOptions): Express {
code: b.code, code: b.code,
add: b.add, add: b.add,
submittedAt: typeof b.submittedAt === 'string' ? b.submittedAt : now(), submittedAt: typeof b.submittedAt === 'string' ? b.submittedAt : now(),
site: typeof b.site === 'string' ? b.site : '',
} }
const summary = store.upsertSubmission(dto) const summary = store.upsertSubmission(dto)
broadcast({ type: 'submission:new', submission: summary }) broadcast({ type: 'submission:new', submission: summary })
@@ -110,6 +122,11 @@ export function createApp(opts: AppOptions): Express {
res.json(store.leaderboard()) res.json(store.leaderboard())
}) })
// Central-mode fleet view: the local instances that have phoned home.
app.get('/instances', requireAnyCode(adminCode, judgeCode), (_req, res) => {
res.json(store.listInstances())
})
// --- ZeroClaw nodes ----------------------------------------------------- // --- ZeroClaw nodes -----------------------------------------------------
// Registration holds bearer tokens → admin only. Prompting is a public // Registration holds bearer tokens → admin only. Prompting is a public
// participant action (like PUT /teams/:id). List/broadcasts never leak tokens. // participant action (like PUT /teams/:id). List/broadcasts never leak tokens.
@@ -150,17 +167,55 @@ export function createApp(opts: AppOptions): Express {
return res.status(401).json({ error: 'unauthorized' }) return res.status(401).json({ error: 'unauthorized' })
} }
const b = req.body ?? {} const b = req.body ?? {}
if (![b.kitId, b.url, b.token, b.claimCode].every((v) => typeof v === 'string' && v)) { // `url` is optional: an App-Lab-containerised node can't see its host LAN IP,
return res.status(400).json({ error: 'kitId, url, token and claimCode are required' }) // but its request is SNAT'd to the host, so we derive http://<source-ip>:<port>
// from what the server actually sees. An explicit url (e.g. from the shell
// self-register) still wins.
let url = typeof b.url === 'string' && b.url ? b.url : ''
if (!url) {
const port = Number.isFinite(Number(b.port)) ? Number(b.port) : 8080
const ip = (req.ip ?? '').replace(/^::ffff:/, '') // unwrap IPv4-mapped IPv6
if (ip) url = `http://${ip}:${port}`
} }
const r = boards.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode }) const token = typeof b.token === 'string' && b.token ? b.token : 'open-lan'
if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) {
return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' })
}
// `hwId` is the durable board key (WiFi MAC + eMMC serial). Older nodes that
// don't send it fall back to keying by kitId, so they still self-register.
const hwId = typeof b.hwId === 'string' && b.hwId ? b.hwId : b.kitId
const r = boards.announce({ hwId, kitId: b.kitId, url, token, claimCode: b.claimCode })
if (r.claimed && r.teamId && nodes) { if (r.claimed && r.teamId && nodes) {
await nodes.register({ teamId: r.teamId, url: b.url, token: b.token }) await nodes.register({ teamId: r.teamId, url, token })
} }
broadcastUnclaimed() broadcastUnclaimed()
res.status(201).json({ kitId: b.kitId, claimed: r.claimed }) res.status(201).json({ kitId: b.kitId, url, claimed: r.claimed })
}) })
// --- federation: local instances phone home (central-mode) --------------
// A per-team local (edge) stack's reporter sidecar registers itself and then
// heartbeats. Fleet-secret gated (same shared secret as self-register). The
// sidecar replays team/submission writes UP via the public PUT/POST routes,
// tagged with `site` (= the instance id), so central becomes the fleet view.
const registerInstance = (req: express.Request, res: express.Response) => {
if (!fleetSecret || !matches(req.header('x-fleet-secret') ?? '', fleetSecret)) {
return res.status(401).json({ error: 'unauthorized' })
}
const b = req.body ?? {}
const id = typeof b.id === 'string' ? b.id : req.params.id
if (typeof id !== 'string' || !id) return res.status(400).json({ error: 'id is required' })
const instance = {
id,
name: typeof b.name === 'string' && b.name ? b.name : id,
lastSeen: now(),
}
store.upsertInstance(instance)
broadcast({ type: 'instance:update', instance })
return res.status(201).json(instance)
}
app.post('/instances/register', registerInstance)
app.post('/instances/:id/heartbeat', registerInstance)
// A participant claims their powered-on board to their team by proving // A participant claims their powered-on board to their team by proving
// possession of the kit's claim code. Public + rate-limited (no operator in // possession of the kit's claim code. Public + rate-limited (no operator in
// the loop); the bearer token moves straight from the pool into the bridge // the loop); the bearer token moves straight from the pool into the bridge
@@ -173,18 +228,29 @@ export function createApp(opts: AppOptions): Express {
app.post('/claim', async (req, res) => { app.post('/claim', async (req, res) => {
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' }) if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {} const b = req.body ?? {}
if (typeof b.kit !== 'string' || typeof b.teamId !== 'string' || typeof b.code !== 'string') { if (typeof b.teamId !== 'string') {
return res.status(400).json({ error: 'kit, teamId and code are required' }) return res.status(400).json({ error: 'teamId is required' })
} }
const result = boards.claim(b.kit, b.code, b.teamId, Date.parse(now())) // 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 = 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.ok) {
if (result.reason === 'unknown') { 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') { if (result.reason === 'rate_limited') {
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' }) return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
} }
return res.status(401).json({ error: 'wrong claim code' }) return res.status(401).json({ error: "wrong code — check what your board is showing on its matrix" })
} }
const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim) const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim)
await nodes.register({ teamId, url: result.board.url, token: result.board.token }) await nodes.register({ teamId, url: result.board.url, token: result.board.token })
@@ -196,7 +262,7 @@ export function createApp(opts: AppOptions): Express {
const team: TeamSnapshot = { const team: TeamSnapshot = {
id: teamId, id: teamId,
name: pickName ?? '', name: pickName ?? '',
kit: b.kit, kit: result.board.kitId,
members: pickMembers ?? [], members: pickMembers ?? [],
domain: typeof prev?.domain === 'string' ? prev.domain : '', domain: typeof prev?.domain === 'string' ? prev.domain : '',
phases: { ...emptyPhases, ...(prev?.phases ?? {}) }, phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
@@ -208,7 +274,7 @@ export function createApp(opts: AppOptions): Express {
broadcast({ type: 'team:update', team }) broadcast({ type: 'team:update', team })
broadcastUnclaimed() // the claimed kit left the pool broadcastUnclaimed() // the claimed kit left the pool
const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false
res.status(201).json({ teamId, kit: b.kit, url: result.board.url, online, resumed: result.resumed, team }) res.status(201).json({ teamId, kit: result.board.kitId, url: result.board.url, online, resumed: result.resumed, team })
}) })
// Instructor action: release a kit back to the unclaimed pool and unbind its // Instructor action: release a kit back to the unclaimed pool and unbind its
@@ -231,6 +297,25 @@ export function createApp(opts: AppOptions): Express {
res.status(204).end() 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) => { app.post('/nodes/:teamId/prompt', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' }) if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {} const b = req.body ?? {}
@@ -243,6 +328,87 @@ export function createApp(opts: AppOptions): Express {
res.status(202).json({ accepted: true }) res.status(202).json({ accepted: true })
}) })
// Onboarding "say hi": prompt the node and WAIT for its reply (blocking) so the
// participant sees their agent answer. Greeting only — never a flash turn.
app.post('/nodes/:teamId/say-hi', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
const message = typeof b.message === 'string' && b.message.trim() ? b.message : 'Hi! Introduce yourself in one sentence.'
const agent = typeof b.agent === 'string' ? b.agent : undefined
try {
const reply = await nodes.sayHi(String(req.params.teamId), message, agent)
if (reply === null) return res.status(404).json({ error: 'no node registered for team' })
res.json({ reply })
} catch {
res.status(502).json({ error: 'your node did not answer — is it online?' })
}
})
// 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 // 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. // a claim. Online reflects the bridge's live /health + SSE view.
app.get('/nodes/:teamId/status', (req, res) => { app.get('/nodes/:teamId/status', (req, res) => {
@@ -253,6 +419,22 @@ export function createApp(opts: AppOptions): Express {
res.json({ teamId, url: view.url, online: view.online }) 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 // Participant-scoped SSE: a team watches only its own board's activity
// (the /ws hub is admin/judge only). Public, keyed by teamId. // (the /ws hub is admin/judge only). Public, keyed by teamId.
app.get('/nodes/:teamId/events', (req, res) => { app.get('/nodes/:teamId/events', (req, res) => {
+47 -5
View File
@@ -11,7 +11,12 @@ const ADMIN = 'admin-code'
const JUDGE = 'judge-code' const JUDGE = 'judge-code'
const FLEET = 'fleet-secret' const FLEET = 'fleet-secret'
const board = { kitId: 'KIT-07', url: 'http://192.168.1.7:8080', token: 'zc_secret_token', claimCode: '418302' } const board: { kitId: string; url: string; token: string; claimCode: string; hwId?: string } = {
kitId: 'KIT-07',
url: 'http://192.168.1.7:8080',
token: 'zc_secret_token',
claimCode: '418302',
}
describe('board self-register + claim', () => { describe('board self-register + claim', () => {
let store: Store let store: Store
@@ -51,13 +56,24 @@ describe('board self-register + claim', () => {
await selfRegister({}, 'wrong-secret').expect(401) await selfRegister({}, 'wrong-secret').expect(401)
}) })
it('validates the body', async () => { it('validates the body (kitId + claimCode required)', async () => {
await selfRegister({ token: '' }).expect(400) await selfRegister({ kitId: '' }).expect(400)
await selfRegister({ claimCode: '' }).expect(400)
}) })
it('accepts a board presenting the fleet secret', async () => { it('accepts a board presenting the fleet secret', async () => {
const res = await selfRegister().expect(201) const res = await selfRegister().expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: false }) expect(res.body).toMatchObject({ kitId: 'KIT-07', url: board.url, claimed: false })
})
it('derives the node url from the source IP when url is omitted', async () => {
const res = await request(app)
.post('/nodes/self-register')
.set('x-fleet-secret', FLEET)
.send({ kitId: 'KIT-08', claimCode: '4821', port: 8080 })
.expect(201)
// supertest connects over loopback → derived host is 127.0.0.1
expect(res.body.url).toMatch(/^http:\/\/127\.0\.0\.1:8080$/)
}) })
it('broadcasts the unclaimed pool and lists it for the instructor', async () => { it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
@@ -112,6 +128,20 @@ describe('board self-register + claim', () => {
expect(pool.body).toEqual({ kits: [] }) expect(pool.body).toEqual({ kits: [] })
}) })
it('code-first: binds by the matrix code with no kit supplied', async () => {
const res = await request(app)
.post('/claim')
.send({ teamId: 'team-07', teamName: 'team_resonance', members: ['A. Rossi'], code: '418302' })
.expect(201)
expect(res.body).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', online: true, resumed: false })
expect(res.body.team).toMatchObject({ id: 'team-07', name: 'team_resonance', members: ['A. Rossi'], deviceConnected: true })
expect(JSON.stringify(res.body)).not.toContain('zc_secret_token')
})
it('code-first: a wrong code is rejected', async () => {
await request(app).post('/claim').send({ teamId: 'team-07', code: '000000' }).expect(401)
})
it('exposes public per-team liveness after a claim', async () => { it('exposes public per-team liveness after a claim', async () => {
await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
@@ -152,7 +182,7 @@ describe('board self-register + claim', () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
// board reboots with a new IP + a fresh paired token and re-announces // board reboots with a new IP + a fresh paired token and re-announces
const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201) const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: true }) expect(res.body).toMatchObject({ kitId: 'KIT-07', url: 'http://192.168.1.9:8080', claimed: true })
// the team's binding is refreshed (new url), still online, still not unclaimed // the team's binding is refreshed (new url), still online, still not unclaimed
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200) const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }]) expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }])
@@ -160,6 +190,18 @@ describe('board self-register + claim', () => {
expect(pool.body).toEqual({ kits: [] }) expect(pool.body).toEqual({ kits: [] })
}) })
it('recognizes the same board by hwId even after its kit label is re-provisioned', async () => {
// a distinct board announces with a durable hwId, then gets claimed
await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-XY', claimCode: '999999' }).expect(201)
await request(app).post('/claim').send({ kit: 'KIT-XY', teamId: 'team-xy', code: '999999' }).expect(201)
// re-provisioned under a new kit label + new IP, SAME hardware → still claimed, no orphan
const res = await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-RENAMED', claimCode: '999999', url: 'http://192.168.1.9:8080' }).expect(201)
expect(res.body).toMatchObject({ kitId: 'KIT-RENAMED', claimed: true })
// the renamed board is not offered as a fresh unclaimed board (only the default KIT-07 remains)
const pool = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
expect(pool.body).toEqual({ kits: ['KIT-07'] })
})
it('lets an instructor release a claimed kit back to the pool for reassignment', async () => { it('lets an instructor release a claimed kit back to the pool for reassignment', async () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only
+43
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim' import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim'
const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({ const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({
hwId: 'hw-07',
kitId: 'KIT-07', kitId: 'KIT-07',
url: 'http://192.168.1.7:8080', url: 'http://192.168.1.7:8080',
token: 'zc_secret_token', token: 'zc_secret_token',
@@ -51,6 +52,25 @@ describe('board registry', () => {
expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99 expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99
}) })
it('claimByCode binds the matching board with no kit, and rejects a wrong code', () => {
const r = createBoardRegistry()
r.announce(board())
expect(r.claimByCode('000000', 'team-07', 0)).toEqual({ ok: false, reason: 'bad_code' })
const out = r.claimByCode('418302', 'team-07', 0)
expect(out).toMatchObject({ ok: true, resumed: false })
expect(out.ok && out.board.kitId).toBe('KIT-07')
expect(r.isClaimed('KIT-07')).toBe(true)
})
it('claimByCode resumes an already-claimed board to its canonical team', () => {
const r = createBoardRegistry()
r.announce(board())
r.claimByCode('418302', 'team-07', 0)
const again = r.claimByCode('418302', 'team-99', 1)
expect(again).toMatchObject({ ok: true, resumed: true })
expect(again.ok && again.board.claimedBy).toBe('team-07')
})
it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => { it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => {
const r = createBoardRegistry() const r = createBoardRegistry()
r.announce(board()) r.announce(board())
@@ -92,4 +112,27 @@ describe('board registry', () => {
r.announce(board()) // board rebooted and re-registered r.announce(board()) // board rebooted and re-registered
expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true }) expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true })
}) })
it('recognizes the same board (durable hwId) even when its kit label changes', () => {
const r = createBoardRegistry()
r.announce(board())
r.claim('KIT-07', '418302', 'team-07', 0)
// re-provisioned under a new kit label + new IP, same hardware → still claimed
const res = r.announce(board({ kitId: 'KIT-NEW', url: 'http://192.168.1.9:8080' }))
expect(res).toEqual({ claimed: true, teamId: 'team-07' })
expect(r.isClaimed('KIT-NEW')).toBe(true)
expect(r.get('KIT-NEW')?.claimedBy).toBe('team-07')
expect(r.unclaimedKits()).toEqual([]) // not a second, separate board
})
it('seeds from persisted boards and reports each mutation via onChange', () => {
const saved: string[] = []
const r = createBoardRegistry(
[{ hwId: 'hw-07', kitId: 'KIT-07', url: 'u', token: 't', claimCode: '418302', claimedBy: 'team-07' }],
(b) => saved.push(`${b.hwId}:${b.claimedBy}`),
)
expect(r.isClaimed('KIT-07')).toBe(true) // restored claim survives restart
r.release('KIT-07')
expect(saved).toContain('hw-07:null') // release persisted
})
}) })
+108 -21
View File
@@ -5,8 +5,13 @@ import { matches } from './auth'
* bearer token (server-side, never sent to a browser), the per-board claim code * bearer token (server-side, never sent to a browser), the per-board claim code
* an attendee proves possession with (printed / QR-encoded on the kit), and the * an attendee proves possession with (printed / QR-encoded on the kit), and the
* team it is bound to once claimed (`null` while unclaimed). * team it is bound to once claimed (`null` while unclaimed).
*
* The durable key is `hwId` — a stable hardware fingerprint (WiFi MAC + eMMC
* serial) that survives IP change, reboot, laptop-wipe, and OS reflash. `kitId`
* is a human-facing label only (it may change if the board is re-provisioned).
*/ */
export interface Board { export interface Board {
hwId: string
kitId: string kitId: string
url: string url: string
token: string token: string
@@ -27,11 +32,17 @@ export type ClaimOutcome =
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' } | { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
export interface BoardRegistry { export interface BoardRegistry {
/** A board announces itself on boot / on its timer. Upserts url/token/claimCode, /** A board announces itself on boot / on its timer, keyed by its stable `hwId`.
* preserves the existing claim, and reports whether it is already claimed. */ * Upserts url/token/claimCode/kitId, preserves the existing claim (so a board
announce(n: { kitId: string; url: string; token: string; claimCode: string }): AnnounceResult * re-announcing under a new IP — or even a new kitId label — is recognized as
* the same board), and reports whether it is already claimed. */
announce(n: { hwId: string; kitId: string; url: string; token: string; claimCode: string }): AnnounceResult
/** Kit ids of boards that are not yet claimed — never url/token/claimCode. */ /** Kit ids of boards that are not yet claimed — never url/token/claimCode. */
unclaimedKits(): { kitId: string }[] unclaimedKits(): { kitId: string }[]
/** Full board snapshot by durable hwId (for persistence / recognition). */
getByHwId(hwId: string): Board | undefined
/** All boards (for persistence). */
all(): Board[]
isClaimed(kitId: string): boolean isClaimed(kitId: string): boolean
/** /**
* Claim (or resume) a board by proving its code. First claim binds it to * Claim (or resume) a board by proving its code. First claim binds it to
@@ -40,8 +51,24 @@ export interface BoardRegistry {
* lost its browser can get back onto its own board. Rate-limited per kit. * lost its browser can get back onto its own board. Rate-limited per kit.
*/ */
claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome
/**
* Claim (or resume) a board by its code ALONE — the attendee proves possession
* by reading the code the board scrolls on its own matrix, with no kit to pick.
* Finds the unique board whose `claimCode` matches; otherwise `bad_code`.
*/
claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome
/** Release a kit back to unclaimed; returns the freed teamId (or null). */ /** Release a kit back to unclaimed; returns the freed teamId (or null). */
release(kitId: string): string | 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 get(kitId: string): Board | undefined
} }
@@ -49,56 +76,116 @@ export interface BoardRegistry {
export const MAX_FAILS = 5 export const MAX_FAILS = 5
export const WINDOW_MS = 60_000 export const WINDOW_MS = 60_000
export function createBoardRegistry(seed: Board[] = []): BoardRegistry { /**
const boards = new Map<string, Board>() * @param seed boards to restore (from the persistent store) on boot.
for (const b of seed) boards.set(b.kitId, { ...b }) * @param onChange called with a board whenever it is created or mutated
const fails = new Map<string, number[]>() // kitId -> recent failed-attempt timestamps (ms) * (announce/claim/release), so the caller can persist it.
*/
export function createBoardRegistry(
seed: Board[] = [],
onChange: (b: Board) => void = () => {},
): BoardRegistry {
const boards = new Map<string, Board>() // keyed by durable hwId
for (const b of seed) boards.set(b.hwId, { ...b })
const fails = new Map<string, number[]>() // hwId -> recent failed-attempt timestamps (ms)
const recentFails = (kitId: string, nowMs: number): number => { const recentFails = (hwId: string, nowMs: number): number => {
const arr = (fails.get(kitId) ?? []).filter((t) => nowMs - t < WINDOW_MS) const arr = (fails.get(hwId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
if (arr.length) fails.set(kitId, arr) if (arr.length) fails.set(hwId, arr)
else fails.delete(kitId) else fails.delete(hwId)
return arr.length return arr.length
} }
// kitId is a display label, not the key — resolve it by scan (fleet is small).
const byKit = (kitId: string): Board | undefined => [...boards.values()].find((b) => b.kitId === kitId)
return { return {
announce(n) { announce(n) {
const claimedBy = boards.get(n.kitId)?.claimedBy ?? null const claimedBy = boards.get(n.hwId)?.claimedBy ?? null
boards.set(n.kitId, { kitId: n.kitId, url: n.url, token: n.token, claimCode: n.claimCode, claimedBy }) const board: Board = {
fails.delete(n.kitId) hwId: n.hwId,
kitId: n.kitId,
url: n.url,
token: n.token,
claimCode: n.claimCode,
claimedBy,
}
boards.set(n.hwId, board)
fails.delete(n.hwId)
onChange(board)
return { claimed: claimedBy !== null, teamId: claimedBy } return { claimed: claimedBy !== null, teamId: claimedBy }
}, },
unclaimedKits() { unclaimedKits() {
return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId })) return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId }))
}, },
getByHwId(hwId) {
return boards.get(hwId)
},
all() {
return [...boards.values()]
},
isClaimed(kitId) { isClaimed(kitId) {
const b = boards.get(kitId) const b = byKit(kitId)
return !!b && b.claimedBy !== null return !!b && b.claimedBy !== null
}, },
claim(kitId, code, teamId, nowMs) { claim(kitId, code, teamId, nowMs) {
const board = boards.get(kitId) const board = byKit(kitId)
if (!board) return { ok: false, reason: 'unknown' } if (!board) return { ok: false, reason: 'unknown' }
if (recentFails(kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' } if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
if (!matches(code, board.claimCode)) { if (!matches(code, board.claimCode)) {
fails.set(kitId, [...(fails.get(kitId) ?? []), nowMs]) fails.set(board.hwId, [...(fails.get(board.hwId) ?? []), nowMs])
return { ok: false, reason: 'bad_code' } return { ok: false, reason: 'bad_code' }
} }
fails.delete(kitId) fails.delete(board.hwId)
if (board.claimedBy === null) { if (board.claimedBy === null) {
board.claimedBy = teamId board.claimedBy = teamId
onChange(board)
return { ok: true, board, resumed: false } return { ok: true, board, resumed: false }
} }
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
}, },
claimByCode(code, teamId, nowMs) {
// Unique per-board codes → the code identifies the board. No match = bad code.
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
if (!board) return { ok: false, reason: 'bad_code' }
if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
fails.delete(board.hwId)
if (board.claimedBy === null) {
board.claimedBy = teamId
onChange(board)
return { ok: true, board, resumed: false }
}
return { ok: true, board, resumed: true }
},
release(kitId) { release(kitId) {
const board = boards.get(kitId) const board = byKit(kitId)
if (!board || board.claimedBy === null) return null if (!board || board.claimedBy === null) return null
const freed = board.claimedBy const freed = board.claimedBy
board.claimedBy = null board.claimedBy = null
onChange(board)
return freed 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) { get(kitId) {
return boards.get(kitId) return byKit(kitId)
}, },
} }
} }
+106 -16
View File
@@ -6,7 +6,9 @@ import type {
ScoreInput, ScoreInput,
ScoreDTO, ScoreDTO,
LeaderboardRow, LeaderboardRow,
InstanceDTO,
} from './types' } from './types'
import type { Board } from './claim'
export interface Store { export interface Store {
upsertTeam(t: TeamSnapshot): void upsertTeam(t: TeamSnapshot): void
@@ -17,9 +19,26 @@ export interface Store {
getSubmission(teamId: string): SubmissionDTO | null getSubmission(teamId: string): SubmissionDTO | null
insertScore(input: ScoreInput, createdAt: string): ScoreDTO insertScore(input: ScoreInput, createdAt: string): ScoreDTO
leaderboard(): LeaderboardRow[] leaderboard(): LeaderboardRow[]
/** Persist a board (keyed by durable hwId) so the registry survives restart. */
saveBoard(b: Board): void
/** All persisted boards, to seed the in-memory registry on boot. */
listBoards(): Board[]
/** Register/refresh a federated local instance (central-mode). */
upsertInstance(i: InstanceDTO): void
/** All known instances (central-mode fleet view). */
listInstances(): InstanceDTO[]
close(): void close(): void
} }
interface BoardRow {
hw_id: string
kit_id: string
url: string
token: string
claim_code: string
claimed_by: string | null
}
interface TeamRow { interface TeamRow {
id: string id: string
name: string name: string
@@ -30,6 +49,7 @@ interface TeamRow {
stats: string stats: string
device_connected: number device_connected: number
updated_at: string updated_at: string
site: string
} }
function rowToTeam(r: TeamRow): TeamSnapshot { function rowToTeam(r: TeamRow): TeamSnapshot {
@@ -43,6 +63,7 @@ function rowToTeam(r: TeamRow): TeamSnapshot {
stats: JSON.parse(r.stats), stats: JSON.parse(r.stats),
deviceConnected: !!r.device_connected, deviceConnected: !!r.device_connected,
updatedAt: r.updated_at, updatedAt: r.updated_at,
site: r.site ?? '',
} }
} }
@@ -60,14 +81,16 @@ export function openStore(path = ':memory:'): Store {
phases TEXT NOT NULL DEFAULT '{}', phases TEXT NOT NULL DEFAULT '{}',
stats TEXT NOT NULL DEFAULT '{}', stats TEXT NOT NULL DEFAULT '{}',
device_connected INTEGER NOT NULL DEFAULT 0, device_connected INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL,
site TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS submissions ( CREATE TABLE IF NOT EXISTS submissions (
team_id TEXT PRIMARY KEY, team_id TEXT PRIMARY KEY,
team_name TEXT NOT NULL DEFAULT '', team_name TEXT NOT NULL DEFAULT '',
code TEXT NOT NULL, code TEXT NOT NULL,
add_json TEXT NOT NULL, add_json TEXT NOT NULL,
submitted_at TEXT NOT NULL submitted_at TEXT NOT NULL,
site TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS scores ( CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -78,35 +101,54 @@ export function openStore(path = ':memory:'): Store {
notes TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS boards (
hw_id TEXT PRIMARY KEY,
kit_id TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
claim_code TEXT NOT NULL DEFAULT '',
claimed_by TEXT
);
CREATE TABLE IF NOT EXISTS instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
last_seen TEXT NOT NULL
);
`) `)
// Migration for DBs created before the `domain` column existed. CREATE TABLE // Migrations for DBs created before a column existed. CREATE TABLE IF NOT
// IF NOT EXISTS won't add it to an existing table, so add it defensively. // EXISTS won't add columns to an existing table, so add them defensively.
try { for (const stmt of [
db.exec(`ALTER TABLE teams ADD COLUMN domain TEXT NOT NULL DEFAULT ''`) `ALTER TABLE teams ADD COLUMN domain TEXT NOT NULL DEFAULT ''`,
} catch { `ALTER TABLE teams ADD COLUMN site TEXT NOT NULL DEFAULT ''`,
/* column already exists — fine */ `ALTER TABLE submissions ADD COLUMN site TEXT NOT NULL DEFAULT ''`,
]) {
try {
db.exec(stmt)
} catch {
/* column already exists — fine */
}
} }
const upsertTeamStmt = db.prepare(` const upsertTeamStmt = db.prepare(`
INSERT INTO teams (id, name, kit, domain, members, phases, stats, device_connected, updated_at) INSERT INTO teams (id, name, kit, domain, members, phases, stats, device_connected, updated_at, site)
VALUES (@id, @name, @kit, @domain, @members, @phases, @stats, @device_connected, @updated_at) VALUES (@id, @name, @kit, @domain, @members, @phases, @stats, @device_connected, @updated_at, @site)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name=excluded.name, kit=excluded.kit, domain=excluded.domain, members=excluded.members, name=excluded.name, kit=excluded.kit, domain=excluded.domain, members=excluded.members,
phases=excluded.phases, stats=excluded.stats, phases=excluded.phases, stats=excluded.stats,
device_connected=excluded.device_connected, updated_at=excluded.updated_at device_connected=excluded.device_connected, updated_at=excluded.updated_at, site=excluded.site
`) `)
const listTeamsStmt = db.prepare('SELECT * FROM teams ORDER BY name') const listTeamsStmt = db.prepare('SELECT * FROM teams ORDER BY name')
const getTeamStmt = db.prepare('SELECT * FROM teams WHERE id = ?') const getTeamStmt = db.prepare('SELECT * FROM teams WHERE id = ?')
const upsertSubStmt = db.prepare(` const upsertSubStmt = db.prepare(`
INSERT INTO submissions (team_id, team_name, code, add_json, submitted_at) INSERT INTO submissions (team_id, team_name, code, add_json, submitted_at, site)
VALUES (@team_id, @team_name, @code, @add_json, @submitted_at) VALUES (@team_id, @team_name, @code, @add_json, @submitted_at, @site)
ON CONFLICT(team_id) DO UPDATE SET ON CONFLICT(team_id) DO UPDATE SET
team_name=excluded.team_name, code=excluded.code, team_name=excluded.team_name, code=excluded.code,
add_json=excluded.add_json, submitted_at=excluded.submitted_at add_json=excluded.add_json, submitted_at=excluded.submitted_at, site=excluded.site
`) `)
const listSubsStmt = db.prepare(` const listSubsStmt = db.prepare(`
SELECT s.team_id, s.team_name, s.submitted_at, 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 EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored
FROM submissions s ORDER BY s.submitted_at FROM submissions s ORDER BY s.submitted_at
`) `)
@@ -128,6 +170,21 @@ export function openStore(path = ':memory:'): Store {
const setPhaseAddStmt = db.prepare( const setPhaseAddStmt = db.prepare(
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`, `UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
) )
const saveBoardStmt = db.prepare(`
INSERT INTO boards (hw_id, kit_id, url, token, claim_code, claimed_by)
VALUES (@hw_id, @kit_id, @url, @token, @claim_code, @claimed_by)
ON CONFLICT(hw_id) DO UPDATE SET
kit_id=excluded.kit_id, url=excluded.url, token=excluded.token,
claim_code=excluded.claim_code, claimed_by=excluded.claimed_by
`)
const listBoardsStmt = db.prepare('SELECT * FROM boards')
const upsertInstanceStmt = db.prepare(`
INSERT INTO instances (id, name, last_seen)
VALUES (@id, @name, @last_seen)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, last_seen=excluded.last_seen
`)
const listInstancesStmt = db.prepare('SELECT * FROM instances ORDER BY name')
return { return {
upsertTeam(t) { upsertTeam(t) {
@@ -141,6 +198,7 @@ export function openStore(path = ':memory:'): Store {
stats: JSON.stringify(t.stats), stats: JSON.stringify(t.stats),
device_connected: t.deviceConnected ? 1 : 0, device_connected: t.deviceConnected ? 1 : 0,
updated_at: t.updatedAt, updated_at: t.updatedAt,
site: t.site ?? '',
}) })
}, },
listTeams() { listTeams() {
@@ -157,10 +215,11 @@ export function openStore(path = ':memory:'): Store {
code: s.code, code: s.code,
add_json: JSON.stringify(s.add), add_json: JSON.stringify(s.add),
submitted_at: s.submittedAt, submitted_at: s.submittedAt,
site: s.site ?? '',
}) })
// mark the team's final phase complete if we know the team // mark the team's final phase complete if we know the team
if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId) 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() { listSubmissions() {
return (listSubsStmt.all() as Array<Omit<SubmissionSummary, 'scored'> & { scored: number }>).map((r) => ({ return (listSubsStmt.all() as Array<Omit<SubmissionSummary, 'scored'> & { scored: number }>).map((r) => ({
@@ -168,6 +227,7 @@ export function openStore(path = ':memory:'): Store {
teamName: r.teamName, teamName: r.teamName,
submittedAt: r.submittedAt, submittedAt: r.submittedAt,
scored: !!r.scored, scored: !!r.scored,
site: r.site ?? '',
})) }))
}, },
getSubmission(teamId) { getSubmission(teamId) {
@@ -202,6 +262,36 @@ export function openStore(path = ':memory:'): Store {
scoreCount: r.scoreCount, scoreCount: r.scoreCount,
})) }))
}, },
saveBoard(b) {
saveBoardStmt.run({
hw_id: b.hwId,
kit_id: b.kitId,
url: b.url,
token: b.token,
claim_code: b.claimCode,
claimed_by: b.claimedBy,
})
},
listBoards() {
return (listBoardsStmt.all() as BoardRow[]).map((r) => ({
hwId: r.hw_id,
kitId: r.kit_id,
url: r.url,
token: r.token,
claimCode: r.claim_code,
claimedBy: r.claimed_by,
}))
},
upsertInstance(i) {
upsertInstanceStmt.run({ id: i.id, name: i.name, last_seen: i.lastSeen })
},
listInstances() {
return (listInstancesStmt.all() as Array<{ id: string; name: string; last_seen: string }>).map((r) => ({
id: r.id,
name: r.name,
lastSeen: r.last_seen,
}))
},
close() { close() {
db.close() db.close()
}, },
+4 -1
View File
@@ -12,6 +12,8 @@ const ADMIN_CODE = process.env.ADMIN_CODE ?? ''
const JUDGE_CODE = process.env.JUDGE_CODE ?? '' const JUDGE_CODE = process.env.JUDGE_CODE ?? ''
const FLEET_SECRET = process.env.FLEET_SECRET ?? '' const FLEET_SECRET = process.env.FLEET_SECRET ?? ''
const CORS_ORIGIN = process.env.CORS_ORIGIN 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) { if (!ADMIN_CODE || !JUDGE_CODE) {
console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests') console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests')
@@ -23,7 +25,7 @@ if (!FLEET_SECRET) {
const store = openStore(DB_PATH) const store = openStore(DB_PATH)
const hub = createHub() const hub = createHub()
const nodes = createNodeBridge({ broadcast: hub.broadcast }) const nodes = createNodeBridge({ broadcast: hub.broadcast })
const boards = createBoardRegistry() const boards = createBoardRegistry(store.listBoards(), (b) => store.saveBoard(b))
const app = createApp({ const app = createApp({
store, store,
broadcast: hub.broadcast, broadcast: hub.broadcast,
@@ -33,6 +35,7 @@ const app = createApp({
nodes, nodes,
boards, boards,
fleetSecret: FLEET_SECRET, fleetSecret: FLEET_SECRET,
localMode: LOCAL_MODE,
}) })
const server = http.createServer(app) const server = http.createServer(app)
+47
View File
@@ -16,22 +16,37 @@ const store = {} as unknown as Store
describe('node bridge + /nodes routes', () => { describe('node bridge + /nodes routes', () => {
let events: WsEvent[] let events: WsEvent[]
let sent: { node: NodeRef; message: string; agent?: string }[] let sent: { node: NodeRef; message: string; agent?: string }[]
let telegramCalls: { node: NodeRef; token: string }[]
let telegramThrows: boolean
let app: ReturnType<typeof createApp> let app: ReturnType<typeof createApp>
beforeEach(() => { beforeEach(() => {
events = [] events = []
sent = [] sent = []
telegramCalls = []
telegramThrows = false
const nodes = createNodeBridge({ const nodes = createNodeBridge({
broadcast: (e) => events.push(e), broadcast: (e) => events.push(e),
ping: async () => true, // pretend the node is online ping: async () => true, // pretend the node is online
send: async (node, message, agent) => { send: async (node, message, agent) => {
sent.push({ node, message, agent }) sent.push({ node, message, agent })
}, },
sendAndWait: async (node, message, agent) => {
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 subscribe: () => () => {}, // no live SSE in the unit test
}) })
app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes }) 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 () => { 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) await request(app).post('/nodes').send({ teamId: 't1', url: 'http://n', token: 'zc_secret' }).expect(401)
@@ -78,6 +93,38 @@ describe('node bridge + /nodes routes', () => {
await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400) await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400)
}) })
it('say-hi waits for the node reply; 404s an unregistered team', async () => {
await request(app).post('/nodes/ghost/say-hi').send({}).expect(404)
await request(app)
.post('/nodes')
.set('x-access-code', ADMIN)
.send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
.expect(201)
const res = await request(app).post('/nodes/t1/say-hi').send({ agent: 'cloud' }).expect(200)
expect(res.body.reply).toMatch(/^echo: /)
// a default greeting is sent when no message is supplied
expect(sent.at(-1)?.message).toMatch(/introduce yourself/i)
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 () => { it('503s when no bridge is configured', async () => {
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE }) const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503) 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') 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' } const flash: WsEvent = { type: 'node:activity', teamId: 't1', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' }
describe('createNodeBridge — per-team activity', () => { 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' }) 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)', () => { it('ignores noisy/internal events (llm_request, plain notes, non-objects)', () => {
expect(mapNodeEvent('t1', { type: 'llm_request' })).toBeNull() expect(mapNodeEvent('t1', { type: 'llm_request' })).toBeNull()
expect(mapNodeEvent('t1', { message: 'No sandbox backend available, using application-layer security' })).toBeNull() expect(mapNodeEvent('t1', { message: 'No sandbox backend available, using application-layer security' })).toBeNull()
+215
View File
@@ -1,3 +1,4 @@
import net from 'node:net'
import type { WsEvent, NodeActivityKind } from './types' import type { WsEvent, NodeActivityKind } from './types'
/** A team's ZeroClaw node: gateway URL + its server-side bearer token. */ /** A team's ZeroClaw node: gateway URL + its server-side bearer token. */
@@ -7,6 +8,36 @@ export interface NodeRef {
token: string 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 { export interface NodeRegistry {
register(ref: NodeRef): void register(ref: NodeRef): void
get(teamId: string): NodeRef | undefined 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`. // 2) Structured log lines carry a `message`.
const message = str(e.message) const message = str(e.message)
if (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)) { if (/compiled and flashed/i.test(message)) {
const addr = message.match(/0x[0-9A-Fa-f]+/)?.[0] const addr = message.match(/0x[0-9A-Fa-f]+/)?.[0]
return activity('flash', addr ? `Flashed to ${addr}` : 'Flashed to the MCU') return activity('flash', addr ? `Flashed to ${addr}` : 'Flashed to the MCU')
@@ -123,6 +168,123 @@ export async function sendPrompt(node: NodeRef, message: string, agent = 'defaul
}) })
} }
/**
* Prompt a node and WAIT for its reply text (the blocking `/webhook` response).
* Only for non-flashing turns (e.g. the onboarding greeting) — a flash turn must
* stay fire-and-forget via {@link sendPrompt} or it hangs the agent's task.
*/
export async function promptAndWait(node: NodeRef, message: string, agent = 'default'): Promise<string> {
const res = await fetch(`${node.url}/webhook?agent=${encodeURIComponent(agent)}`, {
method: 'POST',
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
body: JSON.stringify({ message }),
})
if (!res.ok) throw new Error(`webhook ${res.status}`)
const body = (await res.json().catch(() => ({}))) as { response?: string }
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 { export interface SubscribeOptions {
/** Aborts the whole reconnect loop when fired. */ /** Aborts the whole reconnect loop when fired. */
signal?: AbortSignal signal?: AbortSignal
@@ -219,6 +381,21 @@ export interface NodeBridge {
remove(teamId: string): void remove(teamId: string): void
list(): NodeView[] list(): NodeView[]
prompt(teamId: string, message: string, agent?: string): Promise<boolean> prompt(teamId: string, message: string, agent?: string): Promise<boolean>
/** 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. */ /** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
stopAll(): void stopAll(): void
@@ -230,6 +407,11 @@ export interface NodeBridgeDeps {
/** Injectable for tests. */ /** Injectable for tests. */
ping?: (n: NodeRef) => Promise<boolean> ping?: (n: NodeRef) => Promise<boolean>
send?: (n: NodeRef, m: string, agent?: string) => Promise<void> 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 subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
} }
@@ -243,6 +425,11 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
const registry = deps.registry ?? createNodeRegistry() const registry = deps.registry ?? createNodeRegistry()
const ping = deps.ping ?? pingNode const ping = deps.ping ?? pingNode
const send = deps.send ?? sendPrompt 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 subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
const online = new Map<string, boolean>() const online = new Map<string, boolean>()
const stops = new Map<string, () => void>() const stops = new Map<string, () => void>()
@@ -287,6 +474,34 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
await send(node, message, agent) await send(node, message, agent)
return true return true
}, },
async sayHi(teamId, message, agent) {
const node = registry.get(teamId)
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) { onTeamActivity(teamId, listener) {
let set = teamListeners.get(teamId) let set = teamListeners.get(teamId)
if (!set) { if (!set) {
+27 -1
View File
@@ -27,6 +27,9 @@ export interface TeamSnapshot {
stats: SessionStats stats: SessionStats
deviceConnected: boolean deviceConnected: boolean
updatedAt: string updatedAt: string
/** Federation tag: the local instance (site) this team belongs to. '' on a
* single-fleet deploy; set by the reporter sidecar on a central deploy. */
site?: string
} }
export interface SubmissionDTO { export interface SubmissionDTO {
@@ -35,6 +38,18 @@ export interface SubmissionDTO {
code: string code: string
add: AddLayers add: AddLayers
submittedAt: string submittedAt: string
/** Federation tag — see {@link TeamSnapshot.site}. */
site?: string
}
/**
* A running local (edge) stack that has registered with the central control
* plane. Identified by a stable `site` id; `lastSeen` drives online/offline.
*/
export interface InstanceDTO {
id: string
name: string
lastSeen: string
} }
export interface SubmissionSummary { export interface SubmissionSummary {
@@ -42,6 +57,9 @@ export interface SubmissionSummary {
teamName: string teamName: string
submittedAt: string submittedAt: string
scored: boolean 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 { export interface ScoreInput {
@@ -72,7 +90,13 @@ export interface LeaderboardRow {
export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback' export type NodeActivityKind = 'thinking' | 'tool' | 'flash' | 'error' | 'response' | 'fallback'
export type WsEvent = export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[]; unclaimed?: string[] } | {
type: 'snapshot'
teams: TeamSnapshot[]
submissions: SubmissionSummary[]
unclaimed?: string[]
instances?: InstanceDTO[]
}
| { type: 'team:update'; team: TeamSnapshot } | { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary } | { type: 'submission:new'; submission: SubmissionSummary }
| { type: 'score:new'; teamId: string; total: number } | { type: 'score:new'; teamId: string; total: number }
@@ -80,3 +104,5 @@ export type WsEvent =
| { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string } | { type: 'node:activity'; teamId: string; kind: NodeActivityKind; label: string; ts: string }
// Kit ids of boards that have self-registered but aren't claimed yet. // Kit ids of boards that have self-registered but aren't claimed yet.
| { type: 'unclaimed:update'; kits: string[] } | { type: 'unclaimed:update'; kits: string[] }
// A local instance (site) registered or heartbeated on the central plane.
| { type: 'instance:update'; instance: InstanceDTO }
+1 -1
View File
@@ -26,7 +26,7 @@ describe('collective WS feed', () => {
store = openStore(':memory:') store = openStore(':memory:')
const hub = createHub() const hub = createHub()
const boards = createBoardRegistry([ const boards = createBoardRegistry([
{ kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null }, { hwId: 'hw-05', kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null },
]) ])
const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE }) const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE })
server = http.createServer(app) server = http.createServer(app)
+1
View File
@@ -36,6 +36,7 @@ export function attachWs(
teams: store.listTeams(), teams: store.listTeams(),
submissions: store.listSubmissions(), submissions: store.listSubmissions(),
unclaimed: boards?.unclaimedKits().map((u) => u.kitId) ?? [], unclaimed: boards?.unclaimedKits().map((u) => u.kitId) ?? [],
instances: store.listInstances(),
}), }),
) )
hub.add(ws) hub.add(ws)
+159
View File
@@ -0,0 +1,159 @@
# APESS 2026 — Lecture Deck Revision
**Follow-up to the deck summary.** What changed since the deck was written (ZeroClaw v0.1.0 →
v0.8.3, the network-node pivot, the failure-first reframe, and the flash pathology), the flow the
deck should now tell, and slide-by-slide target copy.
> **Authoritative sources — pull from these, do not retype from memory:**
> - ADD layer titles → `src/lib/addLayers.ts`
> - Schedule → `src/pages/Landing.tsx` (`PROGRAMME`)
> - Connect flow → the network-node model (web-chat-by-IP over LAN), not Web Serial
---
## 1. What needs to change
Grouped by severity. The deck's **spine is sound** — the perception loop, threshold-as-design,
the two-failure-modes block, and the vision slides all still hold. The failure-first block (deck
slides 13–17) is *more* central now, not less. What follows is only what breaks against the app
as shipped and the hardware as measured.
### 🔴 Wrong — contradicts the app or the programme
| # | Slide(s) | Deck says | Reality | Fix |
|---|---|---|---|---|
| C1 | 11, 22 | ADD layers: L1 Actor map · L2 Harness spec · L3 Decision graph · L4 Failure-mode analysis · L5 AI-native redesign | **L1 Domain & events · L2 Skills · L3 Policies · L4 Harness · L5 Loops** | Re-author both slides. The field→layer mapping on 11 collapses entirely — Harness moved to L4, L2 is Skills. Failure is now *woven through* L3/L4/L5, not a standalone layer. |
| C2 | 22, 23 | Modules at 15:00 / 16:00 / 17:00; lecture inside the afternoon | Lecture is a **separate 10:45–12:15 morning session**; hackathon **14:00–19:00**, modules ~14:45 / 16:10 / 17:40 | Retime slide 22; stop implying the lecture is part of the hack block. |
| C3 | 23 | "Open Chrome · Connect your kit · Web Serial · flash ZeroClaw to the UNO Q" | Students **already have the board** with a week of their own sensor work. *Omar* backs up → reflashes → provisions ZeroClaw. Students reach their node **over the LAN via web-chat-by-IP (HTTP + SSE)** — no Web Serial, no self-flash. `apess.redclaw.dev` URL still correct. | Rewrite the mechanism sentence; keep the URL/QR. |
### 🟡 Review — probably-wrong numbers/labels
| # | Slide(s) | Issue | Action |
|---|---|---|---|
| R1 | 2 | "ZeroClaw v0.1.0" + `latency_ms: 318` | We're on **v0.8.3**. 318 ms is a *cloud*-path figure; on-board warm tool call is **~3.8 s** (0.5B). Decide which path the slide shows and label it. |
| R2 | 2, 4, 9 | Model `claude-haiku-4-5` | Verify the actual model string in the shipped config; don't trust the deck. Provider "anthropic" is correct. |
| R3 | 10 | "WebSocket to browser" | Transport is **SSE**, not WebSocket (we specifically avoid the blocking `/webhook`). Say "streamed to the browser (SSE)". |
### 🟢 Careful reframe — new hardware truth
| # | Slide(s) | Issue | Action |
|---|---|---|---|
| H1 | 10 | LED matrix as an agent "Act" output in the loop | **Agent-triggered flashing hangs the chat turn** — openocd's SWD/GPIO activity poisons the turn's task (the flash physically lands, but the turn never acknowledges). So severity→LED must be driven by a **pre-flashed responder sketch the agent talks to**, not the agent re-flashing per event. Never demo "the agent flashes the matrix in the loop" — it visibly hangs. Flashing itself is demoed once, from the CLI, by the instructor. |
### 🟢 Fine as-is
Slides 1, 3, 5–9, 12–21. In particular slide 9's "Dragonwing makes the HTTP call directly, no UART
bridge" is **correct** to our architecture. Vibration bands (15/16) are already environment-tagged.
---
## 2. How the flow should be
The deck's job narrows to one arc: **the sensor already thinks — now design it to fail well.**
The students spent a week making the board *sense*. The deck's payload is the turn from sensing to
*judgment under failure*, ending in the ADD as the deliverable. Seven beats:
1. **Hook** (1–2) — "what if your sensor could think?" → a real on-device result. *It already does;
here's proof from your board.*
2. **Ground the LLM** (3–4) — a function from situation-description to interpretation. Demystify.
3. **The loop** (5–10) — Perceive → Window → Threshold → Reason → Act. The thing they'll configure.
**Land the weight on Threshold (8): the trigger engine is the intelligence.**
4. **The deliverable** (11–12) — every runtime field maps to an ADD layer. The ADD is the design
language, not homework.
5. **The pivot — failure-first** (13–17) — building is easy; designing for the two failure modes is
the work. *This is the spine of the whole session and the school's own theme (Nardin's
robustness/resilience, Georgakis's "timely reaction").* A fail-safe must never degrade to
"nominal."
6. **Vision at scale** (18–21) — one board → bridges, hospitals, fault networks classifying
themselves. The constraint was never compute; it was assuming intelligence lived elsewhere.
7. **Handoff** (22–23) — the corrected schedule, then "connect to your node and start Module 1."
**What moved the flow:** the connect step (7) is no longer "flash the board" — the board is already
an agentic node when they sit down. The deck should say *meet the node you already have*, matching
the app's 14:25 "Meet your node" beat. And the failure block (5) is now explicitly the school's
Week-1/Week-3 through-line, not a generic caveat.
---
## 3. Slide-by-slide targets (only the ones that change)
### Slide 2 · Live output — *review numbers*
- Caption: **"Arduino UNO Q 4GB running ZeroClaw v0.8.3 — the same board at your seat."**
- If the JSON shows the **cloud** path: keep a sub-300 ms–ish latency but label it *cloud*.
- If it shows **on-device**: latency must read seconds, not milliseconds (~3.8 s warm, 0.5B).
- Verify `provider` and the model string against shipped config (R2).
### Slide 9 · Reason — *review model/latency only*
- Keep the architecture claim (direct HTTP from the Linux side, no UART bridge) — it's correct.
- Fix the footer model/latency to match slide 2's resolved values.
### Slide 10 · Act — *transport + LED reframe*
- Three channels stay. Change "WebSocket" → **"streamed to the browser (SSE)."**
- Reframe the LED channel: **"the agent talks to a pre-flashed responder sketch that owns the
matrix"** — it does not re-flash per event. (One-time flash is an instructor CLI step, off-deck.)
### Slide 11 · Output → ADD — *full re-author*
Map each output field to the **current** layers:
| field | layer |
|---|---|
| `classification` | **L3 · Policies** (what the decision authorises) |
| `reasoning` | **L1 · Domain & events** (the actor/event frame it's reasoning over) |
| `confidence` + `latency_ms` | **L4 · Harness** (the runtime envelope) |
| the loop cadence / cooldown | **L5 · Loops** |
| the skills it invoked | **L2 · Skills** |
Footer: *"Failure isn't a sixth layer — it's the question you ask of L3, L4, and L5."*
### Slide 22 · Your work today — *retime + relabel*
Layer chips: **L1 Domain & events · L2 Skills · L3 Policies · L4 Harness · L5 Loops.**
Schedule (verbatim from `Landing.tsx` `PROGRAMME`):
| time | block |
|---|---|
| 10:45–12:15 | Lecture (separate morning session) |
| 14:00 | Arrival & registration — boards backed up & reflashed while you register |
| 14:25 | Meet your node — the board you already know, now carrying an agent |
| 14:45 | Module 1 · Domain & events → **L1** |
| 16:10 | Module 2 · Skills & policies → **L2 + L3** (drive a real sensor, enumerate failure states, set the gate) |
| 17:40 | Module 3 · Harness, loops & submit → **L4 + L5** |
| 19:00 | Judging & award |
Judged on **rigour, not technical complexity** — keep that line.
### Slide 23 · Handoff — *rewrite mechanism*
- Keep: `apess.redclaw.dev` + QR.
- Replace the setup sentence with: **"Your board is already an agentic node. Open the app, connect
to your node on the workshop network, and start Module 1."**
- Delete: Web Serial, Chrome-only requirement, "flash ZeroClaw," OS-specific toolchain install.
---
## 4. How it plays into the flow
- **C1 (layers)** is load-bearing for beats 4 and 7 — if the deck names layers the app doesn't, the
judging rubric and the student's document won't match what the slides promised. This is the same
drift that was a P0 bug in the app; fix the deck to the *same* single source (`addLayers.ts`).
- **C2/C3 (schedule + connect)** land in beat 7 (handoff). They're what students act on in the first
25 minutes; getting them wrong strands people at the door. The corrected flow also *recovers an
hour* by moving the lecture out of the afternoon.
- **H1 (LED/flash)** protects beat 3 (the loop demo). The loop is the deck's centrepiece; a live
agent-flash hang there would undercut the whole "it already thinks" promise. Demo the loop with a
pre-flashed responder; demo flashing once, from the CLI, as a separate instructor moment.
- **The failure block (beat 5)** is the payload the rest of the deck now serves. Everything before it
is setup for "design it to fail well"; everything after (the vision, the handoff) is the reward and
the call to action. Keep it dark, keep it central, and tie it explicitly to the school's own
resilience theme.
---
## 5. Checklist before the deck ships
- [ ] Slide 11 & 22 layer names match `src/lib/addLayers.ts` exactly.
- [ ] Slide 22 schedule matches `Landing.tsx` `PROGRAMME` (lecture 10:45, hack 14:00–19:00).
- [ ] Slide 23 has no Web-Serial / self-flash language; URL + QR retained.
- [ ] Slides 2/9 version = v0.8.3; model string verified against shipped config; latency labelled
cloud-vs-onboard.
- [ ] Slide 10 says SSE; LED channel = pre-flashed responder, not per-event flash.
- [ ] No slide implies the agent flashes the board inside the loop.
</content>
</invoke>
+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
+92
View File
@@ -0,0 +1,92 @@
# APESS Demo Runbook — Multi-Channel Agent Controls the LED Matrix
One on-board agent on the Arduino Uno Q changes the physical **13×8 LED-matrix
animation** on command from **web chat, Telegram, and voice** — same agent, same
`matrix_pattern` tool, all on cloud sonnet via the reliable `/webhook` path.
## Secrets
All three env-only secrets live in **Infisical** on the `icarus` instance (project
`cloud-providers`, env `prod`). Pull them into the environment in one block:
```bash
DOM=http://icarus.lan:8443 # or https://icarus.taila4f562.ts.net
CP=0788e188-b746-4ea0-a4b2-e0c2d0aec1b6 # cloud-providers project id
export INFISICAL_TOKEN=$(infisical login --method=universal-auth \
--client-id=$(cat ~/.infisical/macbook-admin-id) \
--client-secret=$(cat ~/.infisical/macbook-admin-secret) \
--domain=$DOM --silent --plain)
get(){ infisical secrets get "$1" --projectId=$CP --env=prod --domain=$DOM --plain; }
export ANTHROPIC_OAUTH_TOKEN=$(get ANTHROPIC_OAUTH_TOKEN) # Claude Max setup-token (cloud brain)
export NODE_TOKEN=$(get APESS_NODE_TOKEN) # Uno Q gateway bearer token
export ELEVENLABS_API_KEY=$(get ELEVENLABS_API_KEY) # ElevenLabs TTS voice
```
**None of these may be written to disk or committed** — env-only. Infisical is the vault;
`recover.sh` and `serve.py` read them from the environment. (Vault key `APESS_NODE_TOKEN`
maps to the `NODE_TOKEN` env var the scripts expect.)
## Pre-flight (~5 min before, board plugged into USB)
```bash
cd ~/projects/apress
# (secrets exported per above)
./deploy/uno-q/recover.sh # brings up node + verifies sonnet + matrix (all-green)
# voice proxy — leave running in its own terminal:
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=$NODE_TOKEN ELEVENLABS_API_KEY=$ELEVENLABS_API_KEY \
python3 deploy/voice-client/serve.py 8090
```
`recover.sh` re-tunnels (`adb forward :8080`), relaunches the daemon with the cloud
token in its environment, starts the matrix bridge app, and confirms
`agent=demo` is on `claude-sonnet-5` and `matrix_pattern` fires.
**On the workshop LAN:** point `NODE_URL` at the board's LAN IP (`http://192.168.x.x:8080`)
instead of the adb-forwarded `127.0.0.1:8080`, so the browser voice client reaches the
board over the network.
## The three acts
1. **Web chat** — prompt *"show the rain animation"* → matrix changes + live activity feed.
2. **Telegram** — **t.me/Apess2026Bot** → *"change it to a beating heart"* → the *same
physical matrix* changes, driven from a phone. (One-time pairing: `/bind <code>` — the
code prints in the daemon log at startup; grep `bind code`.)
3. **Voice** — **http://localhost:8090** in Chrome → hold-to-talk *"make it wave"* → matrix
changes and the reply is **spoken back in the ElevenLabs voice** (Sarah).
Patterns the agent understands: `off, rain, heart, wave, sparkle, checker, solid, blink`.
## If the board disconnects mid-demo
The recurring USB drop kills the daemon/llama/bridge and loses the env-only cloud token.
Re-plug, then:
```bash
./deploy/uno-q/recover.sh # ~30s, re-injects the token, verifies end-to-end
```
The voice proxy auto-recovers via the re-armed tunnel (no restart needed). If it was
stopped, relaunch the `serve.py` line above.
## Gotchas / facts
- **Cloud token is env-only** — a disconnect loses it; recovery *must* re-export it (the
script uses `$ANTHROPIC_OAUTH_TOKEN` from your shell).
- **Use `/webhook`, not `/ws/chat`** — the WS path builds a fresh agent that omits the
peripheral `matrix_pattern` tool; the voice client's `serve.py` proxies `/webhook` for
this reason.
- **First turn after a fresh daemon** is a touch slower (cold); the pre-flight
`recover.sh` call warms it.
- **ElevenLabs free tier** can only use the premade voices attached to the account (not
"library" voices → 402). Default `EXAVITQu4vr4xnSDxMaL` (Sarah) works.
- **Board serial** `65301572`. **Bridge app**: `~/ArduinoApps/uno-q-bridge`
(`arduino-app-cli`, needs `TMPDIR=/tmp`).
## Where things live
- `deploy/uno-q/recover.sh` — one-command node recovery.
- `deploy/voice-client/` — browser voice client + `serve.py` proxy (STT/TTS + ElevenLabs).
- zeroclaw fork (`fix/uno-q-flash-timeouts`) — resident matrix responder + `matrix_pattern`
tool + Telegram channel.
</content>
+24
View File
@@ -85,6 +85,30 @@ ssh [email protected] "cd ~/projects/apress && docker compose -f deploy/dock
# Remove DNS records if needed. # 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 ## Why this lives on gw-03
- 104 GB free disk, 16 GB Intel RAM, idle CPU - 104 GB free disk, 16 GB Intel RAM, idle CPU
+4
View File
@@ -8,6 +8,10 @@ services:
image: apess-web:latest image: apess-web:latest
container_name: apess-web container_name: apess-web
restart: unless-stopped 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: networks:
- clawbooks-net - clawbooks-net
# Routing is defined in deploy/traefik/apess.yml (file provider) to avoid # 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.
+11
View File
@@ -6,3 +6,14 @@ JUDGE_CODE=jdg-xxxxxxxx
FLEET_SECRET=change-me FLEET_SECRET=change-me
# Host port for the web UI. Use 8080 if the box can't bind privileged :80. # Host port for the web UI. Use 8080 if the box can't bind privileged :80.
WEB_PORT=80 WEB_PORT=80
# --- Federated mode (optional) ----------------------------------------------
# Only needed if you run the reporter sidecar (`--profile federated`) to mirror
# this instance UP to the central control plane. Omit for a purely local room.
# 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 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
+25
View File
@@ -78,6 +78,31 @@ work): a rebooted board re-announces and the API refreshes its binding; a team
that lost its browser just re-scans the QR + re-enters the code to resume. For that lost its browser just re-scans the QR + re-enters the code to resume. For
anything stuck, release the kit from `/admin` and let the team re-claim. anything stuck, release the kit from `/admin` and let the team re-claim.
## 4. Federated mode (optional) — phone home to a central dashboard
For a multi-team event you can run **one local stack per team** (each with its
board) and have every instance mirror its state UP to a **central control
plane** — a fleet dashboard where judges see the whole cohort at once. This is
outbound-only, so it works from behind the room's NAT.
```sh
# in .env, set a UNIQUE SITE_ID per team + the central api base:
# SITE_ID=team-07 SITE_NAME="Team 07" CENTRAL_API=https://apess.redclaw.dev/api
docker compose --env-file .env -f docker-compose.yml --profile federated up -d --build
```
The **reporter** sidecar joins the local network, subscribes to the local API's
WS feed, and replays every `team:update` / submission UP to `CENTRAL_API`,
**namespaced by `SITE_ID`** (so ids never collide across instances) and
`site`-tagged (so central groups by team/site). It registers the instance and
heartbeats on a timer. If the uplink is down, writes queue in an in-memory
outbox and backfill on reconnect — the local workshop never blocks on it.
Central is **observe-only**: it never touches a board (it can't reach the NAT'd
boards — only the local stack drives them). Run the central instance from
`deploy/docker-compose.yml` with the same `FLEET_SECRET`; the reporter presents
it on every federation call.
## Notes ## Notes
- **Data** persists in the `apess-lan-data` volume (`docker compose down` keeps - **Data** persists in the `apess-lan-data` volume (`docker compose down` keeps
+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
+37
View File
@@ -27,6 +27,9 @@ services:
- '${WEB_PORT:-80}:80' # WEB_PORT=8080 if the box can't bind :80 - '${WEB_PORT:-80}:80' # WEB_PORT=8080 if the box can't bind :80
volumes: volumes:
- ./nginx.lan.conf:/etc/nginx/conf.d/default.conf:ro - ./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: depends_on:
- apess-api - apess-api
networks: [apess-lan] networks: [apess-lan]
@@ -44,12 +47,46 @@ services:
# Must match the FLEET_SECRET baked into each board's apess-node.env. # Must match the FLEET_SECRET baked into each board's apess-node.env.
FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET} FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET}
DB_PATH: /data/apess.db 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). # Same-origin via the /api proxy → no CORS needed (API default is permissive).
volumes: volumes:
- apess-lan-data:/data - 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. # Not published: boards + browsers reach the API through the web's /api proxy.
networks: [apess-lan] networks: [apess-lan]
# Optional edge→central reporter. Enable with `--profile federated`; mirrors
# this instance's state UP to the central control plane (outbound-only). Needs
# SITE_ID + CENTRAL_API set (see .env.example). Omit the profile for a purely
# local, offline single-room workshop.
reporter:
build:
context: ../..
dockerfile: deploy/lan/reporter/Dockerfile
image: apess-reporter:lan
container_name: apess-reporter-lan
restart: unless-stopped
profiles: [federated]
environment:
SITE_ID: ${SITE_ID:?set SITE_ID for federated mode}
SITE_NAME: ${SITE_NAME:-}
CENTRAL_API: ${CENTRAL_API:?set CENTRAL_API for federated mode}
FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET}
LOCAL_WS: ws://apess-api-lan:3000/ws
LOCAL_API: http://apess-api-lan:3000
# LOCAL_CODE auths the reporter's read of the local feed — reuse ADMIN_CODE.
LOCAL_CODE: ${ADMIN_CODE:?set ADMIN_CODE}
depends_on:
- apess-api
networks: [apess-lan]
networks: networks:
apess-lan: apess-lan:
driver: bridge driver: bridge
+11
View File
@@ -52,6 +52,17 @@ server {
return 200 "ok\n"; 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, …) # SPA fallback (client-side routes: /workshop, /admin, /judge, …)
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+8
View File
@@ -0,0 +1,8 @@
# APESS reporter sidecar — tiny outbound-only state mirror (edge → central).
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY deploy/lan/reporter/package.json ./
RUN npm install --omit=dev
COPY deploy/lan/reporter/index.mjs ./
CMD ["node", "index.mjs"]
+151
View File
@@ -0,0 +1,151 @@
// APESS reporter sidecar — mirrors one local (edge) stack's state UP to the
// central control plane. Outbound-only (NAT-friendly): it never accepts inbound
// traffic, it just consumes the LOCAL api's WS feed and replays the public write
// contract (PUT /teams/:id, POST /submissions) to CENTRAL, tagged with `site`.
//
// Failed central writes queue in an in-memory outbox and retry, so a flaky or
// offline uplink never blocks — the workshop runs fully on the local stack and
// central backfills when the link returns.
//
// Env:
// SITE_ID stable id for this instance (namespaces every id at central)
// SITE_NAME human label for the fleet dashboard (defaults to SITE_ID)
// LOCAL_WS ws url of the local api feed (default ws://apess-api-lan:3000/ws)
// LOCAL_API http base of the local api (default http://apess-api-lan:3000)
// LOCAL_CODE admin code — auths the WS + the full-submission read
// CENTRAL_API http base of the central api (e.g. https://apess.redclaw.dev/api)
// FLEET_SECRET shared secret central gates federation on
// HEARTBEAT_MS instance heartbeat cadence (default 30000)
import WebSocket from 'ws'
const SITE_ID = must('SITE_ID')
const SITE_NAME = process.env.SITE_NAME || SITE_ID
const LOCAL_WS = process.env.LOCAL_WS || 'ws://apess-api-lan:3000/ws'
const LOCAL_API = (process.env.LOCAL_API || 'http://apess-api-lan:3000').replace(/\/$/, '')
const LOCAL_CODE = must('LOCAL_CODE')
const CENTRAL_API = must('CENTRAL_API').replace(/\/$/, '')
const FLEET_SECRET = must('FLEET_SECRET')
const HEARTBEAT_MS = Number(process.env.HEARTBEAT_MS || 30000)
function must(name) {
const v = process.env[name]
if (!v) {
console.error(`[reporter] missing required env ${name}`)
process.exit(1)
}
return v
}
const log = (...a) => console.log('[reporter]', ...a)
const nsId = (id) => `${SITE_ID}:${id}` // namespace a local id so it never collides at central
// --- offline outbox: {key, run: () => fetch-promise} ------------------------
// keyed so a newer team snapshot collapses the older one still queued.
const outbox = new Map()
function enqueue(key, run) {
outbox.set(key, run)
}
async function flush() {
for (const [key, run] of [...outbox]) {
try {
await run()
outbox.delete(key)
} catch (e) {
// leave it queued; try again next tick
log(`outbox retry pending (${outbox.size}) — ${key}: ${e.message}`)
break // preserve order; stop on first failure
}
}
}
async function central(path, body, method = 'POST') {
const res = await fetch(`${CENTRAL_API}${path}`, {
method,
headers: { 'content-type': 'application/json', 'x-fleet-secret': FLEET_SECRET },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`${method} ${path} → ${res.status}`)
return res
}
// --- replay the public write contract UP, namespaced + site-tagged ----------
function pushTeam(team) {
const id = nsId(team.id)
enqueue(`team:${id}`, () =>
central(`/teams/${encodeURIComponent(id)}`, { ...team, id, site: SITE_ID }, 'PUT'),
)
}
function pushSubmission(sub) {
const teamId = nsId(sub.teamId)
enqueue(`sub:${teamId}`, () =>
central('/submissions', { ...sub, teamId, site: SITE_ID }),
)
}
// The WS submission:new event only carries a summary — fetch the full record
// (add layers + code) from the LOCAL api before replaying it up.
async function fetchFullSubmission(localTeamId) {
const res = await fetch(`${LOCAL_API}/submissions/${encodeURIComponent(localTeamId)}`, {
headers: { 'x-access-code': LOCAL_CODE },
})
if (!res.ok) throw new Error(`local GET /submissions/${localTeamId} → ${res.status}`)
return res.json()
}
// --- instance registration + heartbeat --------------------------------------
async function register() {
enqueue('instance', () => central('/instances/register', { id: SITE_ID, name: SITE_NAME }))
}
function startHeartbeat() {
setInterval(() => {
enqueue('instance', () =>
central(`/instances/${encodeURIComponent(SITE_ID)}/heartbeat`, { name: SITE_NAME }),
)
void flush()
}, HEARTBEAT_MS)
}
// --- local WS subscription (auto-reconnecting) ------------------------------
function connect() {
const ws = new WebSocket(`${LOCAL_WS}?code=${encodeURIComponent(LOCAL_CODE)}`)
ws.on('open', () => log(`connected to local feed ${LOCAL_WS}`))
ws.on('message', async (raw) => {
let ev
try {
ev = JSON.parse(raw.toString())
} catch {
return
}
if (ev.type === 'snapshot') {
// backfill: replay every known team + submission on (re)connect
for (const t of ev.teams ?? []) pushTeam(t)
for (const s of ev.submissions ?? []) {
try {
pushSubmission(await fetchFullSubmission(s.teamId))
} catch (e) {
log(`snapshot submission skip ${s.teamId}: ${e.message}`)
}
}
} else if (ev.type === 'team:update') {
pushTeam(ev.team)
} else if (ev.type === 'submission:new') {
try {
pushSubmission(await fetchFullSubmission(ev.submission.teamId))
} catch (e) {
log(`submission fetch failed ${ev.submission.teamId}: ${e.message}`)
}
}
void flush()
})
ws.on('close', () => {
log('local feed closed — reconnecting in 3s')
setTimeout(connect, 3000)
})
ws.on('error', (e) => log(`local feed error: ${e.message}`))
}
log(`starting — site=${SITE_ID} → central ${CENTRAL_API}`)
await register()
void flush()
startHeartbeat()
connect()
setInterval(() => void flush(), 15000) // periodic drain even when idle
+36
View File
@@ -0,0 +1,36 @@
{
"name": "apess-reporter",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "apess-reporter",
"version": "1.0.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "apess-reporter",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "APESS edge→central reporter sidecar (outbound-only state mirror)",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs"
},
"dependencies": {
"ws": "^8.18.0"
}
}
+12
View File
@@ -23,6 +23,18 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate"; 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 { location = /healthz {
access_log off; access_log off;
return 200 "ok\n"; 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`.
+31 -2
View File
@@ -77,8 +77,11 @@ where systemd isn't usable. Two properties matter:
- It polls **`/health` endpoints, not `pgrep`** — a wedged process passes `pgrep` but - It polls **`/health` endpoints, not `pgrep`** — a wedged process passes `pgrep` but
fails a health check. Process liveness is not service liveness. fails a health check. Process liveness is not service liveness.
- It respects a **warmup grace** (`LLAMA_WARMUP=300`). A cold GGUF load takes **3–5 - It respects a **warmup grace** (`LLAMA_WARMUP=300`); reaping mid-load produces an
minutes**; reaping mid-load produces an infinite restart loop that never converges. infinite restart loop that never converges. The 3–5 minute cold-load figure was measured
against the 1.1 GB coder model — the 409 MB `qwen.gguf` we standardised on came up
**healthy in ~5 s** from a cold boot (2026-07-20). The 300 s grace is now generous rather
than necessary, which is harmless.
Children start under `setsid` so they survive the launching shell closing — plain Children start under `setsid` so they survive the launching shell closing — plain
`nohup … &` inside an `adb shell` does **not** give you that, which is why services `nohup … &` inside an `adb shell` does **not** give you that, which is why services
@@ -187,6 +190,32 @@ Three ways to destroy that advantage — all easy to do by accident:
- **`max_tool_iterations = 6`.** Each iteration is a full model call — ~3 min per task. - **`max_tool_iterations = 6`.** Each iteration is a full model call — ~3 min per task.
Acceptable for a background loop, not for anything interactive. Acceptable for a background loop, not for anything interactive.
### The offline capability boundary (measured 2026-07-20)
Speed is not the only limit — there is a hard capability cliff. Same board, same lean
profile, same on-board 0.5B:
| task | result |
|---|---|
| Call a simple tool (`i2cdetect`, 708-token prompt) | **works — 20 s**, tool call fired, real answer |
| Write + compile + flash a sketch (1,997-token prompt) | **never completed** — 450 s, two identical requests, no tool call ever emitted |
So offline the node can **sense and decide, but it cannot author new code**. Code
generation needs the cloud model. This is the concrete degradation boundary to state in
ADD Layer 4: what still works with no network is the sensing and decision loop over
*already-flashed* firmware — not writing new firmware.
Two related prompt-shape findings from the same session:
- **Imperative, not interrogative.** `"List the I2C devices on the bus."` fires a tool call
in 20 s; `"What sensors can you find on the I2C bus?"` produced **no tool call at all**
in 200 s. Same agent, same 708-token prompt — phrasing was the only variable.
- **It does not hallucinate hardware.** Asked to list I2C devices on a board with an empty
bus, it ran the tool and reported the bus numbers rather than inventing a sensor.
Note also that `BuildFlash` in the SPA routes to the **`cloud`** agent, not `local` — so the
student build/flash exercise does not depend on the boundary above.
### What this means for offline work ### What this means for offline work
On the **0.5B** (measured or extrapolated from the curve above): On the **0.5B** (measured or extrapolated from the curve above):
+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).*
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# agent-mode.sh — flip EVERY agent on the Uno Q between cloud and offline.
#
# cloud (default, workshop): every agent → anthropic.max (claude-sonnet-5),
# full tool set (risk_profile=default), full context (runtime=unoq).
# Nothing feels broken; the on-board fallback is NOT in the way.
# offline (resilience demo): every agent → llamacpp.local (on-board Qwen),
# lean tools (risk_profile=demo) + lean context (runtime=offline) so
# the 0.5B is actually usable. This is the fallback you enable on purpose.
#
# Usage: ./agent-mode.sh [cloud|offline]
# Env: SERIAL (default 65301572)
#
# Restart note: kills the daemon so the supervisor relaunches it with the new
# config. Cloud mode needs the Max token in the supervisor's env — if it's not
# there (e.g. after a bare reboot), run deploy/uno-q/recover.sh first.
set -u
MODE="${1:-cloud}"
SERIAL="${SERIAL:-65301572}"
CFG=/home/arduino/.zeroclaw/config.toml
S(){ adb -s "$SERIAL" shell "$@"; }
case "$MODE" in
cloud) PROV="anthropic.max"; RISK="default"; RT="unoq" ;;
offline) PROV="llamacpp.local"; RISK="demo"; RT="offline" ;;
*) echo "usage: $0 [cloud|offline]"; exit 1 ;;
esac
echo "→ setting ALL agents to: provider=$PROV risk=$RISK runtime=$RT"
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { echo "✗ board $SERIAL not attached"; exit 1; }
S "cp -f $CFG ${CFG}.pre-${MODE}.bak"
# Rewrite every top-level [agents.<name>] block (not its .sub-tables).
S "python3 - <<'PY'
import re
cfg='$CFG'; prov='$PROV'; risk='$RISK'; rt='$RT'
hdr=re.compile(r'^\[agents\.[A-Za-z0-9_]+\]\$')
out=[]; ina=False
for ln in open(cfg).read().split('\n'):
if hdr.match(ln): ina=True
elif ln.startswith('['): ina=False
if ina:
if re.match(r'^\s*model_provider\s*=', ln): ln='model_provider = \"%s\"'%prov
elif re.match(r'^\s*risk_profile\s*=', ln): ln='risk_profile = \"%s\"'%risk
elif re.match(r'^\s*runtime_profile\s*=', ln): ln='runtime_profile = \"%s\"'%rt
out.append(ln)
open(cfg,'w').write('\n'.join(out))
print('rewrote agent blocks')
PY"
echo "→ restarting daemon (supervisor relaunches with new config)…"
S 'for p in $(ps -C zeroclaw -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done'
for i in $(seq 1 15); do
sleep 4
S 'curl -sf -m3 http://127.0.0.1:8080/health >/dev/null 2>&1' && break
printf '.'
done; echo
echo "=== agents now ==="
S "awk '/^\[agents\.[A-Za-z0-9_]+\]\$/{a=substr(\$0,9,length(\$0)-9)} /^model_provider/{print a\": \"\$3}' $CFG"
if [ "$MODE" = cloud ]; then
N=$(S 'P=$(pgrep -f "[z]eroclaw daemon" | head -1); cat /proc/$P/environ 2>/dev/null | tr "\0" "\n" | grep -c "ANTHROPIC_OAUTH_TOKEN"')
[ "${N:-0}" -ge 1 ] && echo "✓ cloud token present in daemon env" \
|| echo "✗ cloud token NOT in daemon env — run deploy/uno-q/recover.sh (with ANTHROPIC_OAUTH_TOKEN exported)"
fi
echo "done ($MODE)."
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Back up a student's Uno Q before reflashing it for the APESS workshop.
#
# ./backup-uno-q.sh <adb-serial> [team-label] [outdir]
#
# Students arrive having spent a week doing their own sensor work on these
# boards. THAT WORK IS IRREPLACEABLE — this runs before provision-uno-q.sh and
# must succeed before anything is overwritten.
#
# Strategy: DENYLIST, not allowlist. We do not know where a given team put
# their data (App Lab project dir, a loose CSV, a sketch folder), so we capture
# all of /home/arduino and exclude only what we can reproduce ourselves:
# the GGUF models, the Arduino core cache, our llama/zeroclaw binaries, the
# embedded SPA, and build output. On a reference board that leaves ~1-2 MB;
# a board with a week of logged data will be larger but still quick.
#
# The archive is VERIFIED READABLE before the script reports success — an
# unverified backup is not a backup.
set -euo pipefail
SERIAL="${1:?usage: backup-uno-q.sh <adb-serial> [team-label] [outdir]}"
LABEL="${2:-$SERIAL}"
OUTDIR="${3:-./backups}"
a() { adb -s "$SERIAL" "$@"; }
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
NAME="unoq-${LABEL}-${STAMP}"
REMOTE_TAR="/tmp/${NAME}.tar.gz"
LOCAL_TAR="${OUTDIR}/${NAME}.tar.gz"
LOCAL_MANIFEST="${OUTDIR}/${NAME}.manifest.txt"
# Reproducible — ours, or regenerable. Everything else is theirs and is kept.
#
# DELIBERATELY NOT EXCLUDED: ~/.local/share/arduino-app-cli/examples (~37M).
# Those are Arduino's stock App Lab examples, so in principle reproducible —
# but several of them (real-time-accelerometer, anomaly-detection,
# air-quality-monitoring) are exactly what a student doing sensor work would
# open and then edit IN PLACE. Excluding them would silently discard a week of
# work to save 25MB. We keep them. A backup is insurance, not a size contest.
EXCLUDES=(
'--exclude=./models' # 1.5G of GGUF weights — we push these
'--exclude=./.arduino15' # ~570M Arduino cores/index — arduino-cli refetches
'--exclude=./llama' # llama.cpp binaries — we push these
'--exclude=./web-dist' # embedded SPA build — we push this
'--exclude=./.cache' # regenerable
'--exclude=./lost+found'
'--exclude=./zeroclaw' # the daemon binary...
'--exclude=./zeroclaw.*' # ...and its .bak copies
'--exclude=./llama8083.log'
'--exclude=./zc-daemon.log'
'--exclude=./zc-supervisor.log'
'--exclude=*/build' # sketch build output — recompiled on demand
)
mkdir -p "$OUTDIR"
echo "==> [1/5] board reachable?"
a shell 'echo ok' >/dev/null 2>&1 || { echo " FAIL: board $SERIAL not reachable over adb"; exit 1; }
a shell 'test -d /home/arduino' >/dev/null 2>&1 || { echo " FAIL: /home/arduino missing"; exit 1; }
echo " $SERIAL ok"
echo "==> [2/5] inventory what will be captured"
a shell '
cd /home/arduino || exit 1
echo " user-work directories:"
for d in sketches Arduino ArduinoApps .arduino-bricks; do
if [ -e "$d" ]; then printf " %-18s %s\n" "$d" "$(du -sh "$d" 2>/dev/null | cut -f1)"; fi
done
echo " data-shaped files (top 10 by size):"
find . -maxdepth 4 \( -name "*.csv" -o -name "*.tsv" -o -name "*.dat" -o -name "*.json" -o -name "*.txt" -o -name "*.py" \) \
-not -path "./.arduino15/*" -not -path "./.cache/*" -not -path "*/build/*" -not -path "./.zeroclaw/*" \
-printf " %s\t%p\n" 2>/dev/null | sort -rn | head -10
echo " (none found)"
' 2>/dev/null || true
echo "==> [3/5] archive on board"
a shell "cd /home/arduino && tar czf '$REMOTE_TAR' ${EXCLUDES[*]} . 2>/dev/null; echo done" >/dev/null
SIZE="$(a shell "du -h '$REMOTE_TAR' 2>/dev/null | cut -f1" | tr -d '\r\n ')"
echo " archive: $SIZE"
echo "==> [4/5] pull + verify"
a pull "$REMOTE_TAR" "$LOCAL_TAR" >/dev/null 2>&1 || { echo " FAIL: could not pull archive"; exit 1; }
# An unverified backup is not a backup: list the archive and require real content.
if ! tar tzf "$LOCAL_TAR" > "$LOCAL_MANIFEST" 2>/dev/null; then
echo " FAIL: archive is not readable — DO NOT REFLASH THIS BOARD"
exit 1
fi
ENTRIES="$(wc -l < "$LOCAL_MANIFEST" | tr -d ' ')"
if [ "$ENTRIES" -lt 5 ]; then
echo " FAIL: archive has only $ENTRIES entries — suspiciously empty, DO NOT REFLASH"
exit 1
fi
echo " verified: $ENTRIES entries, manifest at $LOCAL_MANIFEST"
echo "==> [5/5] clean up board temp"
a shell "rm -f '$REMOTE_TAR'" >/dev/null 2>&1 || true
cat <<EOF
BACKUP OK — safe to reflash $SERIAL
archive : $LOCAL_TAR ($SIZE)
manifest: $LOCAL_MANIFEST
restore (after reflash):
adb -s $SERIAL push $LOCAL_TAR /tmp/restore.tar.gz
adb -s $SERIAL shell 'cd /home/arduino && tar xzf /tmp/restore.tar.gz && rm /tmp/restore.tar.gz'
EOF
@@ -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). # without a human approver (the webhook path is non-interactive).
[risk_profiles.default] [risk_profiles.default]
level = "supervised" level = "supervised"
allowed_tools = ["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 = ["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] [runtime_profiles.unoq]
agentic = true agentic = true
@@ -94,7 +94,13 @@ strict_tool_parsing = false
# Agents — one per provider strategy. APESS routes here via ?agent=; callers # Agents — one per provider strategy. APESS routes here via ?agent=; callers
# pass the alias explicitly (see sendPrompt in src/lib/api.ts). # 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 enabled = true
model_provider = "custom.claude" model_provider = "custom.claude"
risk_profile = "default" risk_profile = "default"
@@ -104,14 +110,17 @@ runtime_profile = "unoq"
# telegram) by pasting their @BotFather token; the reload-watcher applies it. # telegram) by pasting their @BotFather token; the reload-watcher applies it.
channels = ["telegram.default"] 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 [agents.cloud] # cloud only, no fallback
enabled = true enabled = false
model_provider = "custom.cloud" model_provider = "custom.cloud"
risk_profile = "default" risk_profile = "default"
runtime_profile = "unoq" runtime_profile = "unoq"
[agents.local] # on-board Qwen only (fully offline) [agents.local] # on-board Qwen only (fully offline)
enabled = true enabled = false
model_provider = "llamacpp.local" model_provider = "llamacpp.local"
risk_profile = "default" risk_profile = "default"
runtime_profile = "unoq" runtime_profile = "unoq"
@@ -139,7 +148,7 @@ mention_only = false
# [channels.voice_duplex.default] # [channels.voice_duplex.default]
# enabled = true # enabled = true
[agents.chaos] # simulated cloud outage → falls back to on-board Qwen [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" model_provider = "custom.dead"
risk_profile = "default" risk_profile = "default"
runtime_profile = "unoq" 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 provision() { # kit serial -> 0 ok / 1 fail
local kit="$1" serial="$2" env="$ENVDIR/$1.env" local kit="$1" serial="$2" env="$ENVDIR/$1.env"
[ -r "$env" ] || { echo " ! no env file for $kit ($env)"; return 1; } [ -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" 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 "$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 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)" echo " ok — modalities (reload-watcher up; lockdown staged)"
fi 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 # default skills — install every bundled skill (the comprehensive arduino-uno-q
# reference + the fork's granular set) into every agent's workspace, so each # reference + the fork's granular set) into every agent's workspace, so each
# node has them by default. Best-effort. # 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
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# provision-node-app.sh — install the "ZeroClaw Node" App Lab app onto a Uno Q and
# carry in its runtime bits (binary + config + .secret_key + token + node env), then
# start it. The app itself is secret-free; this script provisions the secrets.
#
# Usage (board on USB, secrets in env):
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-…
# KIT_ID=crimson-otter CLAIM_CODE=4821 FLEET_SECRET=apress2026 \
# APESS_URL=http://192.168.x.x:3000 ./deploy/uno-q/provision-node-app.sh
#
# 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; }
[ -f "$NODE_APP_DIR/app.yaml" ] || { bad "app not found at $NODE_APP_DIR"; exit 1; }
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set"; exit 1; }
echo "→ pushing the app to $DEST"
S "mkdir -p $DEST/bin $DEST/.zeroclaw"
adb -s "$SERIAL" push "$NODE_APP_DIR/app.yaml" "$DEST/app.yaml" >/dev/null
adb -s "$SERIAL" push "$NODE_APP_DIR/sketch" "$DEST/" >/dev/null
adb -s "$SERIAL" push "$NODE_APP_DIR/python" "$DEST/" >/dev/null
ok "app files"
echo "→ carrying in runtime bits (secret-bearing — provisioned, not committed)"
# binary: reuse the board's known-good one (already the right arch)
S "cp -f /home/arduino/zeroclaw $DEST/bin/zeroclaw && chmod +x $DEST/bin/zeroclaw"
# config + enc2 key (the config's secrets are bound to this key)
S "cp -f /home/arduino/.zeroclaw/config.toml $DEST/.zeroclaw/config.toml"
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.)
S "rm -rf $DEST/.zeroclaw/shared/skills; mkdir -p $DEST/.zeroclaw/shared && cp -rf /home/arduino/.zeroclaw/shared/skills $DEST/.zeroclaw/shared/skills"
# cloud token (env-only → written to the board's app dir, never to the repo)
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" | S "cat > $DEST/.zeroclaw/oauth_token"
# node env (self-register inputs)
S "cat > $DEST/.zeroclaw/apess-node.env" <<EOF
KIT_ID=${KIT_ID:-node-$SERIAL}
CLAIM_CODE=${CLAIM_CODE:-$(( (RANDOM % 9000) + 1000 ))}
FLEET_SECRET=${FLEET_SECRET:-}
APESS_URL=${APESS_URL:-}
GATEWAY_PORT=${GATEWAY_PORT:-8080}
EOF
ok "binary + config + .secret_key + oauth_token + apess-node.env"
echo "→ starting the app (compiles+flashes the matrix sketch, launches daemon in-container)"
S "cd $DEST && TMPDIR=/tmp timeout 300 arduino-app-cli app start $DEST 2>&1 | tail -4"
# The App Lab app IS the boot mechanism now. Remove the legacy @reboot supervisor
# cron so it doesn't race the app for :8080 on boot (it wins, and the app fails).
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
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# recover.sh — restore the Uno Q board's LOCAL DEV binding after a USB re-plug.
#
# 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
#
# Usage:
# ./recover.sh # one recovery pass, then exit
# ./recover.sh --watch # run forever: recover on every (re)connect
#
# 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}"
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' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
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
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[*]})"
# 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"
# 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, 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.) 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) ## Prefer the frame API (always available)
`Arduino_LED_Matrix` is bundled with the `arduino:zephyr` core — **no lib install**. `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 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 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 ## Default I²C addresses
@@ -32,7 +32,7 @@ void loop() { int v = knob.get(); /* ... */ }
## Tips ## 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. should match the table above.
- Qwiic is 3.3 V, like the rest of the Arduino headers. - 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. - 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`. - `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. - 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 ## On-board LEDs
- RGB LED 1/2 are MPU-owned (`/sys/class/leds/*`, use the `sysfs_led` tool). - 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] [Service]
Type=simple Type=simple
User=arduino 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 # 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 # the peripheral tools (uno_q_flash etc.). `gateway start` on an older build
# leaves the agent unable to flash. # leaves the agent unable to flash.
+53
View File
@@ -0,0 +1,53 @@
# APESS Voice → Node client
Talk to the on-board agent and change the LED-matrix animation **by voice** — same
agent, same `matrix_pattern` tool as web chat and Telegram.
- **STT + TTS run in the browser** (Web Speech API) — no ElevenLabs, no keys.
- **`serve.py` serves the page AND proxies `/webhook`** to the node on the same origin,
so the browser needs no CORS and no bearer token, and we reuse the reliable
`/webhook` path (the `/ws/chat` path drops the peripheral matrix tool, so we avoid it).
## Run it
The node is reached over USB via `adb forward`, or by its LAN IP on the day.
```bash
# 1. expose the board's gateway locally (USB path)
adb forward tcp:8080 tcp:8080
# 2. serve the client + proxy (tokens stay server-side, never in the browser)
cd deploy/voice-client
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=<gateway-bearer-token> \
ELEVENLABS_API_KEY=<sk_...> python3 serve.py 8090
# on the day, point NODE_URL at the board's LAN IP instead:
# NODE_URL=http://192.168.x.x:8080 NODE_TOKEN=... ELEVENLABS_API_KEY=... python3 serve.py 8090
# 3. open http://localhost:8090 in Chrome
```
Then: the dot goes green (proxy reachable), **hold** the circle, say
*"show the wave animation"*, release. The node runs the agent → `matrix_pattern` →
the matrix changes, and the reply is spoken back.
`localhost` is a secure context so Chrome grants mic access; the proxy hop is
server-side so there is no CORS. One process, one origin, no keys. Chrome required
(Web Speech API).
## TTS: ElevenLabs vs browser
`serve.py` synthesizes replies with **ElevenLabs** when `ELEVENLABS_API_KEY` is set
(server-side `/tts` endpoint → the client plays the returned MP3); otherwise the client
falls back to the browser's built-in `speechSynthesis` voice. The client learns which
mode is active from `GET /config`, so no client change is needed either way.
- Default voice: **Sarah** (`EXAVITQu4vr4xnSDxMaL`). Override with `ELEVENLABS_VOICE_ID`.
- **Free-tier gotcha:** free ElevenLabs accounts can only use the ~21 *premade voices
attached to the account* — not "library" voices (e.g. Rachel `21m00…`), which return
HTTP 402 `paid_plan_required`. List usable voices:
`curl -s https://api.elevenlabs.io/v1/voices -H "xi-api-key: $ELEVENLABS_API_KEY"`.
- Model: `eleven_turbo_v2_5` (low latency) — override with `ELEVENLABS_MODEL`.
(The gateway also has a native voice-duplex path, but it shares the `/ws/chat`
peripheral-tool gap noted above and needs a fork fix first.)
</content>
+173
View File
@@ -0,0 +1,173 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APESS · Voice → Node</title>
<style>
:root { --bg:#0d0d0f; --fg:#eae6df; --dim:#8a857c; --accent:#e8543f; --line:#26242a; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
min-height:100vh; display:flex; flex-direction:column; align-items:center; }
header { width:100%; border-bottom:1px solid var(--line); padding:14px 18px;
text-transform:uppercase; letter-spacing:.18em; font-size:12px; color:var(--dim);
display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
.cfg { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.cfg input { background:#141317; color:var(--fg); border:1px solid var(--line);
padding:6px 8px; font:inherit; font-size:12px; border-radius:4px; }
.cfg input#ip { width:150px; }
.cfg input#agent { width:80px; }
button { font:inherit; cursor:pointer; }
.conn { padding:6px 10px; border:1px solid var(--line); border-radius:4px;
background:transparent; color:var(--fg); font-size:12px; text-transform:uppercase; letter-spacing:.1em; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#5a5750; margin-right:6px; vertical-align:middle; }
.dot.on { background:#4caf72; } .dot.err { background:var(--accent); }
main { flex:1; width:100%; max-width:720px; padding:24px 18px; display:flex; flex-direction:column; gap:18px; }
.mic { align-self:center; width:150px; height:150px; border-radius:50%; border:2px solid var(--line);
background:#141317; color:var(--fg); font-size:13px; text-transform:uppercase; letter-spacing:.12em;
display:flex; align-items:center; justify-content:center; transition:all .15s; user-select:none; }
.mic:hover { border-color:var(--dim); }
.mic.live { border-color:var(--accent); background:#2a1512; box-shadow:0 0 0 6px rgba(232,84,63,.12); }
.mic:disabled { opacity:.4; cursor:not-allowed; }
.hint { text-align:center; color:var(--dim); font-size:12px; margin-top:-8px; }
.log { display:flex; flex-direction:column; gap:12px; }
.row { border:1px solid var(--line); border-radius:6px; padding:12px 14px; }
.row .who { font-size:10px; text-transform:uppercase; letter-spacing:.16em; color:var(--dim); margin-bottom:6px; }
.row.you { border-color:#2f3a44; } .row.node { border-color:#3a2f2c; }
.row.tool { border-style:dashed; color:var(--dim); font-size:12px; }
.txt { font-size:15px; line-height:1.5; white-space:pre-wrap; }
.interim { color:var(--dim); font-style:italic; }
</style>
</head>
<body>
<header>
<span>APESS · VOICE → NODE</span>
<div class="cfg">
<input id="ip" placeholder="board-ip:8080" />
<input id="agent" value="demo" />
<button class="conn" id="connect"><span class="dot" id="dot"></span><span id="connlabel">Connect</span></button>
</div>
</header>
<main>
<button class="mic" id="mic" disabled>Hold&nbsp;to&nbsp;talk</button>
<div class="hint" id="hint">Connect to your node, then hold the circle and speak.</div>
<div class="log" id="log"></div>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
const ipEl=$('ip'), agentEl=$('agent'), micEl=$('mic'), logEl=$('log'),
dot=$('dot'), connLabel=$('connlabel'), hint=$('hint'), connectBtn=$('connect');
// The node address is configured on serve.py; the browser only picks the agent.
ipEl.style.display = 'none';
agentEl.value = localStorage.getItem('apess_agent') || 'demo';
let connected=false;
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { hint.textContent = 'This browser has no Web Speech API — use Chrome.'; }
function setConn(state){ // 'on' | 'err' | ''
dot.className = 'dot' + (state ? ' '+state : '');
connected = state==='on';
connLabel.textContent = connected ? 'Ready' : (state==='err'?'Retry':'Connect');
micEl.disabled = !connected || !SR;
if (connected) hint.textContent = 'Hold the circle, speak, release. The node acts and talks back.';
}
function addRow(cls, who, text){
const r=document.createElement('div'); r.className='row '+cls;
r.innerHTML=`<div class="who">${who}</div><div class="txt"></div>`;
r.querySelector('.txt').textContent=text; logEl.appendChild(r);
r.scrollIntoView({behavior:'smooth',block:'end'}); return r;
}
// Transport: POST to a SAME-ORIGIN /webhook that serve.py proxies to the node's
// gateway (reliable path — the WS path drops peripheral tools). No CORS, no auth
// in the browser (serve.py holds the bearer token).
function connect(){
const agent = agentEl.value.trim() || 'demo';
localStorage.setItem('apess_agent', agent);
setConn('');
fetch('/ping').then(r => setConn(r.ok?'on':'err')).catch(()=>setConn('err'));
}
let ttsMode = 'browser', curAudio = null;
fetch('/config').then(r=>r.json()).then(c=>{ ttsMode = c.tts||'browser'; }).catch(()=>{});
async function speak(text){
if (!text) return;
// stop anything currently playing (barge-in)
try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch(e){}
if (curAudio) { try{ curAudio.pause(); }catch(e){} curAudio=null; }
if (ttsMode === 'elevenlabs') {
try {
const r = await fetch('/tts', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({text})});
if (r.ok) {
const url = URL.createObjectURL(await r.blob());
curAudio = new Audio(url); curAudio.onended=()=>URL.revokeObjectURL(url);
await curAudio.play(); return;
}
} catch(e){ /* fall through to browser TTS */ }
}
if (window.speechSynthesis) {
const u = new SpeechSynthesisUtterance(text);
u.rate = 1.02; u.pitch = 1.0; window.speechSynthesis.speak(u);
}
}
async function send(text){
if (!text) return;
addRow('you','You',text);
const agent = agentEl.value.trim() || 'demo';
const pending = addRow('node','Node','…');
try {
const r = await fetch('/webhook?agent='+encodeURIComponent(agent), {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({message:text})
});
const j = await r.json();
const reply = (j.response || j.error || '(no response)').trim();
pending.querySelector('.txt').textContent = reply;
speak(reply);
} catch(e) {
pending.querySelector('.txt').textContent = 'error: '+e.message;
}
}
// --- push-to-talk: hold the circle, speak, release ---
let rec=null, listening=false, finalText='';
function startListen(){
if (!SR || !connected || listening) return;
finalText=''; listening=true; micEl.classList.add('live'); micEl.textContent='Listening…';
rec = new SR(); rec.lang='en-US'; rec.interimResults=true; rec.continuous=false;
rec.onresult = e => {
let interim='';
for (let i=e.resultIndex;i<e.results.length;i++){
const t=e.results[i][0].transcript;
if (e.results[i].isFinal) finalText+=t; else interim+=t;
}
hint.innerHTML = '<span class="interim">'+(finalText+interim||'…')+'</span>';
};
rec.onerror = () => {};
rec.onend = () => { listening=false; micEl.classList.remove('live'); micEl.textContent='Hold to talk';
const t=finalText.trim(); hint.textContent='Hold the circle and speak.'; if (t) send(t); };
try { rec.start(); } catch(e){ listening=false; }
}
function stopListen(){ if (rec && listening) { try{ rec.stop(); }catch(e){} } }
micEl.addEventListener('mousedown', startListen);
micEl.addEventListener('mouseup', stopListen);
micEl.addEventListener('mouseleave', stopListen);
micEl.addEventListener('touchstart', e=>{e.preventDefault();startListen();},{passive:false});
micEl.addEventListener('touchend', e=>{e.preventDefault();stopListen();},{passive:false});
connectBtn.addEventListener('click', connect);
setConn('');
connect(); // auto-check the proxy on load
})();
</script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""APESS voice client host + gateway proxy.
Serves index.html AND proxies POST /webhook to the node's ZeroClaw gateway on the
same origin — so the browser needs no CORS and no bearer token, and we reuse the
reliable /webhook path (the /ws/chat path drops the peripheral matrix tool).
Usage:
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=zc_xxx python3 serve.py [port]
- NODE_URL node gateway base (default http://127.0.0.1:8080; via `adb forward
tcp:8080 tcp:8080` over USB, or the board's LAN IP:8080 on the day).
- NODE_TOKEN gateway bearer token (kept server-side, never sent to the browser).
- port local port to serve on (default 8090). Open http://localhost:<port>.
localhost is a secure context, so the browser grants mic access; the proxy hop is
server-side, so there is no CORS. One process, one origin.
"""
import os, sys, json, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
NODE_URL = os.environ.get("NODE_URL", "http://127.0.0.1:8080").rstrip("/")
NODE_TOKEN = os.environ.get("NODE_TOKEN", "")
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
# Optional ElevenLabs TTS (server-side; key never reaches the browser). If unset,
# the client falls back to the browser's built-in speechSynthesis voice.
ELEVEN_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
ELEVEN_VOICE = os.environ.get("ELEVENLABS_VOICE_ID", "EXAVITQu4vr4xnSDxMaL") # Sarah (free-tier usable)
ELEVEN_MODEL = os.environ.get("ELEVENLABS_MODEL", "eleven_turbo_v2_5")
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass # quiet
def _send(self, code, body, ctype="application/json"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/ping":
return self._send(200, b'{"ok":true}')
if self.path == "/config":
mode = "elevenlabs" if ELEVEN_KEY else "browser"
return self._send(200, json.dumps({"tts": mode}).encode())
path = "/index.html" if self.path in ("/", "") else self.path.split("?")[0]
fp = os.path.normpath(os.path.join(HERE, path.lstrip("/")))
if not fp.startswith(HERE) or not os.path.isfile(fp):
return self._send(404, b"not found", "text/plain")
ctype = "text/html" if fp.endswith(".html") else "text/plain"
with open(fp, "rb") as f:
self._send(200, f.read(), ctype)
def do_POST(self):
if self.path == "/tts":
return self._tts()
if not self.path.startswith("/webhook"):
return self._send(404, b'{"error":"not found"}')
n = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(n)
q = self.path[len("/webhook"):] # keep ?agent=...
req = urllib.request.Request(
f"{NODE_URL}/webhook{q}", data=body, method="POST",
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {NODE_TOKEN}"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
self._send(resp.status, resp.read())
except urllib.error.HTTPError as e:
self._send(e.code, e.read() or b'{"error":"upstream"}')
except Exception as e:
self._send(502, json.dumps({"error": str(e)}).encode())
def _tts(self):
if not ELEVEN_KEY:
return self._send(503, b'{"error":"tts disabled"}')
n = int(self.headers.get("Content-Length", 0))
try:
text = json.loads(self.rfile.read(n)).get("text", "").strip()
except Exception:
text = ""
if not text:
return self._send(400, b'{"error":"no text"}')
payload = json.dumps({
"text": text, "model_id": ELEVEN_MODEL,
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
}).encode()
req = urllib.request.Request(
f"https://api.elevenlabs.io/v1/text-to-speech/{ELEVEN_VOICE}",
data=payload, method="POST",
headers={"xi-api-key": ELEVEN_KEY, "Content-Type": "application/json",
"Accept": "audio/mpeg"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
self._send(200, resp.read(), "audio/mpeg")
except urllib.error.HTTPError as e:
self._send(e.code, e.read() or b'{"error":"tts upstream"}')
except Exception as e:
self._send(502, json.dumps({"error": str(e)}).encode())
if __name__ == "__main__":
if not NODE_TOKEN:
print("WARN: NODE_TOKEN is empty — gateway calls will 401.", file=sys.stderr)
print(f"APESS voice client → {NODE_URL}")
print(f"TTS: {'ElevenLabs ('+ELEVEN_VOICE+')' if ELEVEN_KEY else 'browser (speechSynthesis)'}")
print(f"open http://localhost:{PORT}")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
+4 -2
View File
@@ -6,16 +6,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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." /> <meta name="description" content="APESS 2026 Workshop — on-device agentic systems for structural intelligence. FabLab Torino, July 27." />
<title>APESS 2026 · Workshop</title> <title>APESS 2026 · Workshop</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
rel="preload" rel="preload"
as="style" 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'" onload="this.onload=null;this.rel='stylesheet'"
/> />
<noscript> <noscript>
<link <link
rel="stylesheet" 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> </noscript>
</head> </head>
+3
View File
@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-slot": "^1.2.5",
"@xyflow/react": "^12.11.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
@@ -23,6 +24,7 @@
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router-dom": "^7.17.0", "react-router-dom": "^7.17.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"three": "0.160.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
@@ -33,6 +35,7 @@
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "0.160.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.8", "@vitest/ui": "^4.1.8",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
+237 -2
View File
@@ -11,6 +11,9 @@ importers:
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.5 specifier: ^1.2.5
version: 1.2.5(@types/[email protected])([email protected]) 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: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -32,9 +35,12 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
three:
specifier: 0.160.0
version: 0.160.0
zustand: zustand:
specifier: ^5.0.14 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: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^10.0.1 specifier: ^10.0.1
@@ -57,6 +63,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/[email protected]) version: 19.2.3(@types/[email protected])
'@types/three':
specifier: 0.160.0
version: 0.160.0
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.0.2([email protected](@types/[email protected])([email protected])) version: 6.0.2([email protected](@types/[email protected])([email protected]))
@@ -509,6 +518,24 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 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]': '@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -532,6 +559,15 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} 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]': '@typescript-eslint/[email protected]':
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -638,6 +674,22 @@ packages:
'@vitest/[email protected]': '@vitest/[email protected]':
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} 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]: [email protected]:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -734,6 +786,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
[email protected]:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
[email protected]: [email protected]:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -768,6 +823,44 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 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]: [email protected]:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -914,6 +1007,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
@@ -1172,6 +1268,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
[email protected]:
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
@@ -1468,6 +1567,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
[email protected]:
resolution: {integrity: sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==}
[email protected]: [email protected]:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1550,6 +1652,11 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 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]: [email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -1690,6 +1797,21 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} 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]: [email protected]:
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -2086,6 +2208,27 @@ snapshots:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
assertion-error: 2.0.1 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]': {}
'@types/[email protected]': {} '@types/[email protected]': {}
@@ -2106,6 +2249,17 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 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])': '@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -2254,6 +2408,31 @@ snapshots:
convert-source-map: 2.0.0 convert-source-map: 2.0.0
tinyrainbow: 3.1.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]): [email protected]([email protected]):
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@@ -2345,6 +2524,8 @@ snapshots:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -2370,6 +2551,42 @@ snapshots:
[email protected]: {} [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]: [email protected]:
dependencies: dependencies:
whatwg-mimetype: 5.0.0 whatwg-mimetype: 5.0.0
@@ -2518,6 +2735,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -2729,6 +2948,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
braces: 3.0.3 braces: 3.0.3
@@ -3012,6 +3233,8 @@ snapshots:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -3083,6 +3306,10 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
[email protected]([email protected]):
dependencies:
react: 19.2.7
[email protected]: {} [email protected]: {}
[email protected](@types/[email protected])([email protected]): [email protected](@types/[email protected])([email protected]):
@@ -3167,7 +3394,15 @@ snapshots:
[email protected]: {} [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: optionalDependencies:
'@types/react': 19.2.17 '@types/react': 19.2.17
react: 19.2.7 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

+22 -7
View File
@@ -3,24 +3,39 @@ import { Landing } from '@/pages/Landing'
import { TeamRegistration } from '@/pages/TeamRegistration' import { TeamRegistration } from '@/pages/TeamRegistration'
import { Lecture } from '@/pages/Lecture' import { Lecture } from '@/pages/Lecture'
import { EnvSetup } from '@/pages/EnvSetup' import { EnvSetup } from '@/pages/EnvSetup'
import { Module1 } from '@/pages/Module1' import { ModuleMakeup } from '@/pages/ModuleMakeup'
import { Module2 } from '@/pages/Module2' import { ModuleDashboard } from '@/pages/ModuleDashboard'
import { AddBuilder } from '@/pages/AddBuilder' import { AddBuilder } from '@/pages/AddBuilder'
import { Admin } from '@/pages/Admin' import { Admin } from '@/pages/Admin'
import { Judge } from '@/pages/Judge' import { Judge } from '@/pages/Judge'
import { CockpitLayout } from '@/components/cockpit/CockpitLayout'
import { ProceedProvider } from '@/lib/ProceedContext'
import { useCollectiveSync } from '@/lib/useCollectiveSync' import { useCollectiveSync } from '@/lib/useCollectiveSync'
import { useApplyTheme } from '@/lib/useApplyTheme'
export default function App() { export default function App() {
useCollectiveSync() useCollectiveSync()
useApplyTheme()
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Landing />} /> <Route path="/" element={<Landing />} />
<Route path="/workshop" element={<TeamRegistration />} /> {/* The workshop flow runs inside the persistent cockpit shell. The
<Route path="/workshop/setup" element={<EnvSetup />} /> ProceedProvider lets each phase publish its advance button into the
<Route path="/workshop/module1" element={<Module1 />} /> shell's sidebar. Module 1 = Skills & policies, Module 2 = UnoQ Dashboard. */}
<Route path="/workshop/module2" element={<Module2 />} /> <Route
<Route path="/workshop/add" element={<AddBuilder />} /> element={
<ProceedProvider>
<CockpitLayout />
</ProceedProvider>
}
>
<Route path="/workshop" element={<TeamRegistration />} />
<Route path="/workshop/setup" element={<EnvSetup />} />
<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="/lecture" element={<Lecture />} />
<Route path="/admin" element={<Admin />} /> <Route path="/admin" element={<Admin />} />
<Route path="/judge" element={<Judge />} /> <Route path="/judge" element={<Judge />} />
+7 -17
View File
@@ -1,5 +1,6 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { ADD_LAYERS } from '@/lib/addLayers'
function LayerBlock({ n, title, body }: { n: number; title: string; body: ReactNode }) { function LayerBlock({ n, title, body }: { n: number; title: string; body: ReactNode }) {
return ( return (
@@ -20,7 +21,6 @@ export function AddDocument() {
const team = useSession((s) => s.team) const team = useSession((s) => s.team)
const add = useSession((s) => s.add) const add = useSession((s) => s.add)
const domain = useSession((s) => s.domain) const domain = useSession((s) => s.domain)
const stats = useSession((s) => s.stats)
return ( return (
<article <article
@@ -38,23 +38,13 @@ export function AddDocument() {
</div> </div>
</header> </header>
<LayerBlock n={1} title="Domain & events" body={add.L1} /> {ADD_LAYERS.map((l) => (
<LayerBlock n={2} title="Skills" body={add.L2} /> <LayerBlock key={l.key} n={l.n} title={l.title} body={add[l.key]} />
<LayerBlock n={3} title="Policies" body={add.L3} /> ))}
<LayerBlock n={4} title="Harness" body={add.L4} />
<LayerBlock n={5} title="Loops" body={add.L5} />
<div className="grid sm:grid-cols-2 gap-4 border-t border-border pt-4 break-inside-avoid"> <div className="border-t border-border pt-4 break-inside-avoid space-y-1">
<div className="space-y-1"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Domain</div>
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Domain</div> <div className="font-mono text-[11px]">{domain || '—'}</div>
<div className="font-mono text-[11px]">{domain || '—'}</div>
</div>
<div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Session</div>
<div className="font-mono text-[11px]">
{stats.calls} frames · {stats.nominal} nominal · {stats.anomalous} anomalous · {stats.critical} critical
</div>
</div>
</div> </div>
</article> </article>
) )
+27 -2
View File
@@ -1,13 +1,21 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { AddLayerForm } from './AddLayerForm' import { AddLayerForm } from './AddLayerForm'
import { useSession } from '@/store/session' 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('AddLayerForm', () => { describe('AddLayerForm', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
mockAsk.mockReset()
}) })
it('writes a string layer (L2) to the store as the user types', async () => { it('writes a string layer (L2) to the store as the user types', async () => {
@@ -29,4 +37,21 @@ describe('AddLayerForm', () => {
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance') await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
expect(useSession.getState().add.L1).toBe('structural resonance') expect(useSession.getState().add.L1).toBe('structural resonance')
}) })
it('Refine is disabled while empty, then reformats the text via the node', async () => {
const user = userEvent.setup()
useSession.getState().setAddLayer('L1', 'rough notes about impact spikes')
mockAsk.mockResolvedValue('Domain: impact monitoring.\n\n- Impact spike\n- Sustained sway')
render(<AddLayerForm layer="L1" title="Layer 1" />)
await user.click(screen.getByRole('button', { name: /refine/i }))
await waitFor(() => expect(useSession.getState().add.L1).toMatch(/Impact spike/))
// the reformatted text replaced the original notes
expect(useSession.getState().add.L1).not.toMatch(/rough notes/)
})
it('Refine is disabled when the layer is empty', () => {
render(<AddLayerForm layer="L1" title="Layer 1" />)
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
})
}) })
+46 -6
View File
@@ -1,7 +1,9 @@
import { useId } from 'react' import { useId, useState } from 'react'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useSession, type AddLayers } from '@/store/session' import { useSession, type AddLayers } from '@/store/session'
import { askNode } from '@/lib/api'
export interface AddLayerFormProps { export interface AddLayerFormProps {
layer: keyof AddLayers layer: keyof AddLayers
@@ -13,13 +15,37 @@ export interface AddLayerFormProps {
/** /**
* Keystone ADD capture component. Drives one layer of the 5-layer Agent Design * Keystone ADD capture component. Drives one layer of the 5-layer Agent Design
* Document — each layer is free text rendered as a single textarea. All edits * Document — each layer is free text rendered as a single textarea, with a
* flow straight into the session store. * "Refine" wand that asks the team's node to reformat + structure what they wrote
* (meaning preserved) so it's submission-ready.
*/ */
export function AddLayerForm({ layer, title, description, placeholder }: AddLayerFormProps) { export function AddLayerForm({ layer, title, description, placeholder }: AddLayerFormProps) {
const value = useSession((s) => s.add[layer]) const value = useSession((s) => s.add[layer])
const setAddLayer = useSession((s) => s.setAddLayer) const setAddLayer = useSession((s) => s.setAddLayer)
const teamId = useSession((s) => s.teamId)
const baseId = useId() const baseId = useId()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const refine = async () => {
const text = value.trim()
if (!text || busy) return
setBusy(true)
setError(null)
try {
const refined = await askNode(
teamId,
`Reformat and lightly structure the following notes for the "${title}" section of an Agent Design Document. ` +
`Preserve the author's meaning and facts — do NOT invent new content. Improve clarity and grammar, and add ` +
`light structure (short paragraphs or bullets) where it helps readability. Return ONLY the improved text, no preamble.\n\nNotes:\n${text}`,
)
if (refined.trim()) setAddLayer(layer, refined.trim())
} catch {
setError('Could not reach your node — say hi first, then try again.')
} finally {
setBusy(false)
}
}
return ( return (
<Card> <Card>
@@ -29,15 +55,29 @@ export function AddLayerForm({ layer, title, description, placeholder }: AddLaye
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="flex items-center justify-between gap-3">
{title} <label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
</label> {title}
</label>
<Button
type="button"
size="sm"
variant="secondary"
className="h-7 px-2.5 font-mono text-[11px] tracking-wider"
disabled={!value.trim() || busy}
onClick={refine}
title="Reformat and structure your notes"
>
{busy ? 'Refining…' : '🪄 Refine'}
</Button>
</div>
<Textarea <Textarea
id={baseId} id={baseId}
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(e) => setAddLayer(layer, e.target.value)} onChange={(e) => setAddLayer(layer, e.target.value)}
/> />
{error && <p role="alert" className="text-xs text-red-500 leading-relaxed">{error}</p>}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+3 -10
View File
@@ -1,11 +1,5 @@
import type { SubmissionDTO } from '@/types' import type { SubmissionDTO } from '@/types'
import { ADD_LAYERS } from '@/lib/addLayers'
const LAYERS: { key: 'L2' | 'L3' | 'L4' | 'L5'; title: string }[] = [
{ key: 'L2', title: 'Reasoning policy' },
{ key: 'L3', title: 'Action contract' },
{ key: 'L4', title: 'Failure modes' },
{ key: 'L5', title: 'AI-native redesign' },
]
function Block({ n, title, body }: { n: number; title: string; body: string }) { function Block({ n, title, body }: { n: number; title: string; body: string }) {
return ( return (
@@ -31,9 +25,8 @@ export function AddReview({ submission }: AddReviewProps) {
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{submission.code}</div> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{submission.code}</div>
<h3 className="text-lg font-bold tracking-tight">{submission.teamName || submission.teamId}</h3> <h3 className="text-lg font-bold tracking-tight">{submission.teamName || submission.teamId}</h3>
</header> </header>
<Block n={1} title="Domain & events" body={submission.add.L1} /> {ADD_LAYERS.map((l) => (
{LAYERS.map((l, i) => ( <Block key={l.key} n={l.n} title={l.title} body={submission.add[l.key]} />
<Block key={l.key} n={i + 2} title={l.title} body={submission.add[l.key]} />
))} ))}
</article> </article>
) )
+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>
)
}
+42 -16
View File
@@ -5,28 +5,35 @@ import { claimBoard, ClaimError, type ClaimResult } from '@/lib/api'
export interface BoardClaimProps { export interface BoardClaimProps {
teamId: string teamId: string
kit: string
teamName: string teamName: string
members: string[]
connected: boolean connected: boolean
port: string | null port: string | null
onClaimed: (result: ClaimResult) => void onClaimed: (result: ClaimResult) => void
/** Pre-fill the code (from the kit QR's ?code= param). */ /** 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 initialCode?: string
} }
/** The three physical bring-up steps an attendee performs before claiming. */ /** Self-service bring-up: download the App Lab app, import it, Run it, enter the code. */
const STEPS = [ const STEPS = [
'Plug your Uno Q into power over USB-C — the 13×8 matrix lights up.', 'Download the board app below.',
'Wait ~30 s for it to boot and join the workshop network.', 'On your Uno Q, open App Lab → “Import an app” → pick the downloaded file.',
'Enter the 6-digit claim code printed on your kit sticker.', '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.',
] ]
// Served same-origin from the web container (see deploy/nginx: location /download/).
const DOWNLOAD_URL = '/download/apess-onboard.zip'
/** /**
* The board bring-up wizard: walks the attendee through powering on their Uno Q * The board bring-up wizard for pre-deployed devices: the attendee runs the
* and claims it to their team by proving the kit's claim code. On success the * setup script on their own Uno Q, which self-registers the node and shows a
* board is bound server-side (its bearer token never touches the browser). * code on its matrix; entering that code binds the board to the team. The
* bearer token never touches the browser.
*/ */
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed, initialCode }: BoardClaimProps) { export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, onDisconnect, initialCode }: BoardClaimProps) {
const [code, setCode] = useState(initialCode ?? '') const [code, setCode] = useState(initialCode ?? '')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -34,9 +41,20 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
if (connected) { if (connected) {
return ( return (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3" data-testid="board-connected"> <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"> <div className="flex items-center justify-between gap-2">
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" /> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium">Board claimed</span> <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>
<div className="font-mono text-[10px] text-muted-foreground mt-1">{port}</div> <div className="font-mono text-[10px] text-muted-foreground mt-1">{port}</div>
</div> </div>
@@ -49,7 +67,7 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
const result = await claimBoard({ teamId, kit, teamName, code: trimmed }) const result = await claimBoard({ teamId, teamName, members, code: trimmed })
onClaimed(result) onClaimed(result)
} catch (e) { } catch (e) {
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.') setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
@@ -68,18 +86,26 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
</li> </li>
))} ))}
</ol> </ol>
<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"
>
<span aria-hidden>↓</span> Download the board app
</a>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label="Claim code" aria-label="Claim code"
inputMode="numeric" inputMode="numeric"
placeholder="418302" placeholder="code on your matrix"
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && claim()} onKeyDown={(e) => e.key === 'Enter' && claim()}
className="font-mono" className="font-mono"
/> />
<Button onClick={claim} disabled={busy || !code.trim()}> <Button onClick={claim} disabled={busy || !code.trim()}>
{busy ? 'Claiming…' : 'Claim board'} {busy ? 'Binding…' : 'Bind board'}
</Button> </Button>
</div> </div>
{error && ( {error && (
+5 -2
View File
@@ -12,11 +12,14 @@ let liveOnEvent: ((e: WsEvent) => void) | null = null
const closeSpy = vi.fn() const closeSpy = vi.fn()
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
AGENT: 'default',
sendPrompt: (...args: unknown[]) => sendPrompt(...args), sendPrompt: (...args: unknown[]) => sendPrompt(...args),
openTeamActivity: (_teamId: string, onEvent: (e: WsEvent) => void) => { openTeamActivity: (_teamId: string, onEvent: (e: WsEvent) => void) => {
liveOnEvent = onEvent liveOnEvent = onEvent
return closeSpy return closeSpy
}, },
// OpenYourNode (rendered here) reads the runtime mode.
getMode: () => Promise.resolve({ localMode: false }),
})) }))
describe('BuildFlash', () => { describe('BuildFlash', () => {
@@ -39,13 +42,13 @@ describe('BuildFlash', () => {
expect(screen.getByText(/live board/i)).toBeInTheDocument() 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() const user = userEvent.setup()
render(<MemoryRouter><BuildFlash /></MemoryRouter>) render(<MemoryRouter><BuildFlash /></MemoryRouter>)
await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO') await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
await user.click(screen.getByRole('button', { name: /working|send/i })) 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 // a flash event streams in over the (mocked) SSE feed
act(() => { act(() => {
+60 -18
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { OpenYourNode } from '@/components/OpenYourNode' import { OpenYourNode } from '@/components/OpenYourNode'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api' import { sendPrompt, openTeamActivity, AGENT } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types' import type { NodeActivityKind, WsEvent } from '@/types'
interface Entry { interface Entry {
@@ -14,6 +14,26 @@ interface Entry {
label: string label: string
} }
/**
* Starter prompts. Deliberately sensor-agnostic — teams wired different devices
* during their own sensing week, so the agent discovers what is actually on the
* bus rather than being told. The last one needs no sensor at all, as a fallback
* for hardware that will not enumerate.
*
* KEEP THESE IMPERATIVE. Measured on the board 2026-07-20: the on-board 0.5B
* reliably calls its tools when told to do something ("List the I2C devices on
* the bus." -> tool call, 20s) but stalls without ever calling one when asked a
* question ("What sensors can you find on the I2C bus?" -> no tool call, >200s).
* Same agent, same 708-token prompt; phrasing was the only variable. Anything
* added here should be an instruction, not a question.
*/
const EXAMPLE_PROMPTS = [
'List the I2C devices on the bus',
'Read my sensor and print a value once a second',
'Light the matrix red when the reading crosses a threshold I set',
'Scroll GO CLAWS on the LED matrix',
]
const KIND_DOT: Record<NodeActivityKind, string> = { const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground', thinking: 'bg-muted-foreground',
tool: 'bg-amber', tool: 'bg-amber',
@@ -50,7 +70,7 @@ export function BuildFlash() {
setBusy(true) setBusy(true)
try { try {
await sendPrompt(teamId, msg, 'cloud') await sendPrompt(teamId, msg, AGENT)
} catch { } catch {
append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' }) append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' })
setBusy(false) setBusy(false)
@@ -68,26 +88,48 @@ export function BuildFlash() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<p className="text-sm text-muted-foreground leading-relaxed max-w-md"> <p className="text-sm text-muted-foreground leading-relaxed max-w-md">
Ask your node to build something — it uses its skills, writes a sketch, and flashes the MCU. Point the agent at the sensor you already have wired. It finds the device, writes a sketch using
The conversation lives in the node; watch it happen there while the activity feed streams here. its skills, and flashes the MCU — sense, decide, act, on your own hardware. The conversation lives
in the node; the activity feed streams here.
<br />
<span className="text-foreground">
Phrase it as an instruction, not a question.
</span>{' '}
“List the I2C devices” works; “what sensors can you find?” tends to stall without ever calling a
tool. That asymmetry is itself a failure mode of small on-device models — worth a line in Layer 3.
</p> </p>
<OpenYourNode variant="inline" className="shrink-0 mt-0.5" /> <OpenYourNode variant="inline" className="shrink-0 mt-0.5" />
</div> </div>
<div className="flex gap-2"> <div className="space-y-2">
<Input <div className="flex flex-wrap gap-1.5">
aria-label="Prompt your board" {EXAMPLE_PROMPTS.map((ex) => (
placeholder="e.g. scroll the message GO CLAWS on the LED matrix" <button
value={prompt} key={ex}
onChange={(e) => setPrompt(e.target.value)} type="button"
onKeyDown={(e) => { onClick={() => setPrompt(ex)}
if (e.key === 'Enter') void run() className="font-mono text-[10px] px-2 py-1 rounded border border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-left"
}} >
className="font-mono text-sm" {ex}
/> </button>
<Button onClick={() => void run()} disabled={busy || !prompt.trim()}> ))}
{busy ? 'Working…' : 'Send'} </div>
</Button>
<div className="flex gap-2">
<Input
aria-label="Prompt your board"
placeholder="Ask your node to read your sensor and act on it…"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void run()
}}
className="font-mono text-sm"
/>
<Button onClick={() => void run()} disabled={busy || !prompt.trim()}>
{busy ? 'Working…' : 'Send'}
</Button>
</div>
</div> </div>
<ul data-testid="activity-log" className="space-y-1.5 min-h-[3rem]"> <ul data-testid="activity-log" className="space-y-1.5 min-h-[3rem]">
+9 -4
View File
@@ -16,10 +16,15 @@ describe('DomainPicker', () => {
expect(useSession.getState().domain).toBe('air quality') 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 />) render(<DomainPicker />)
for (const label of ['Skills', 'Policies', 'Harness', 'Loops']) { expect(screen.getByLabelText(/your domain/i)).toHaveValue('structural stress')
expect(screen.getByText(label)).toBeInTheDocument() })
}
it('is just the name — no refine / generation controls', () => {
render(<DomainPicker />)
expect(screen.queryByRole('button', { name: /refine/i })).toBeNull()
expect(screen.queryByText('Skills')).toBeNull()
}) })
}) })
+16 -41
View File
@@ -2,19 +2,9 @@ import { useId } from 'react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
/** Generic, domain-agnostic scaffolding prompts for the four design dimensions
* the team builds next. Deliberately NOT a fixed catalog — just questions. */
const DIMENSION_HINTS: { label: string; prompt: string }[] = [
{ label: 'Skills', prompt: 'What domain knowledge must it know?' },
{ label: 'Policies', prompt: 'What may it do autonomously vs. need approval?' },
{ label: 'Harness', prompt: 'When does it decide locally vs. escalate?' },
{ label: 'Loops', prompt: 'How often does it check its world + report by exception?' },
]
/** /**
* Names the problem domain the team's node is for, plus the events it senses. * Names the problem domain the team is tackling. Saved to the session store so
* Bound straight to the session store's free-text `domain`. Below the input we * the modules ahead frame everything around it. Just the name — no generation.
* surface generic scaffolding prompts for the next four design dimensions.
*/ */
export function DomainPicker() { export function DomainPicker() {
const domain = useSession((s) => s.domain) const domain = useSession((s) => s.domain)
@@ -22,35 +12,20 @@ export function DomainPicker() {
const inputId = useId() const inputId = useId()
return ( return (
<div className="space-y-5"> <div className="space-y-2">
<div className="space-y-2"> <label htmlFor={inputId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
<label htmlFor={inputId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> Your domain
Your domain </label>
</label> <Input
<Input id={inputId}
id={inputId} placeholder="e.g. image measurement · structural stress · air quality"
placeholder="e.g. image measurement · structural stress · air quality" value={domain}
value={domain} onChange={(e) => setDomain(e.target.value)}
onChange={(e) => setDomain(e.target.value)} />
/> <p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed"> Name the domain your agent is for and the events it senses — ideally the one you have been
Name the domain your node is for and the events it senses. This frames everything you design next. measuring already. This frames everything you design next.
</p> </p>
</div>
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
What you&rsquo;ll design next
</div>
<div className="grid sm:grid-cols-2 gap-3">
{DIMENSION_HINTS.map((d) => (
<div key={d.label} className="rounded-md border border-border px-3 py-2.5">
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">{d.label}</div>
<div className="text-sm text-muted-foreground leading-snug mt-1">{d.prompt}</div>
</div>
))}
</div>
</div>
</div> </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>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { MemberFields } from './MemberFields'
function Wrapper({ initial = [] as string[] }) {
const [members, setMembers] = useState(initial)
return (
<>
<MemberFields members={members} onChange={setMembers} />
<output data-testid="committed">{members.join(',')}</output>
</>
)
}
describe('MemberFields', () => {
it('renders one empty row by default', () => {
render(<Wrapper />)
expect(screen.getByLabelText('Member 1')).toBeInTheDocument()
})
it('commits trimmed, non-empty names to the parent', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.type(screen.getByLabelText('Member 1'), ' A. Rossi ')
expect(screen.getByTestId('committed')).toHaveTextContent('A. Rossi')
})
it('adds a field below when "+" is clicked', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('button', { name: /add member/i }))
expect(screen.getByLabelText('Member 2')).toBeInTheDocument()
})
it('seeds a row per existing member and can remove one', async () => {
const user = userEvent.setup()
render(<Wrapper initial={['A. Rossi', 'K. Tanaka']} />)
expect(screen.getByLabelText('Member 1')).toHaveValue('A. Rossi')
await user.click(screen.getByRole('button', { name: /remove member 1/i }))
expect(screen.getByTestId('committed')).toHaveTextContent('K. Tanaka')
expect(screen.getByTestId('committed')).not.toHaveTextContent('A. Rossi')
})
it('caps at 5 members', () => {
render(<Wrapper initial={['A', 'B', 'C', 'D', 'E']} />)
expect(screen.getByRole('button', { name: /add member/i })).toBeDisabled()
})
})
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const MAX_MEMBERS = 5
export interface MemberFieldsProps {
members: string[]
onChange: (next: string[]) => void
}
/**
* Inline member entry: one text field per member with a "+" to append another
* row below, and a "×" to remove a row. The parent store only ever sees the
* trimmed, non-empty names; empty rows are a local editing affordance.
*/
export function MemberFields({ members, onChange }: MemberFieldsProps) {
// Seed local rows from the parent (always at least one row to type into).
const [rows, setRows] = useState<string[]>(members.length ? members : [''])
const commit = (next: string[]) => {
setRows(next)
onChange(next.map((r) => r.trim()).filter(Boolean))
}
const setRow = (i: number, value: string) => {
const next = rows.slice()
next[i] = value
commit(next)
}
const addRow = () => {
if (rows.length >= MAX_MEMBERS) return
setRows([...rows, '']) // don't commit — empty row adds nothing to the store
}
const removeRow = (i: number) => {
const next = rows.length > 1 ? rows.filter((_, idx) => idx !== i) : ['']
commit(next)
}
const full = rows.length >= MAX_MEMBERS
const filled = rows.filter((r) => r.trim()).length
return (
<div className="space-y-2" data-testid="member-fields">
{rows.map((row, i) => (
<div key={i} className="flex gap-2 items-center">
<span className="font-mono text-[10px] text-muted-foreground w-4 shrink-0 text-right">
{i + 1}
</span>
<Input
aria-label={`Member ${i + 1}`}
placeholder="e.g. A. Rossi"
value={row}
onChange={(e) => setRow(i, e.target.value)}
className="font-mono text-sm"
/>
<button
type="button"
aria-label={`Remove member ${i + 1}`}
onClick={() => removeRow(i)}
className="text-muted-foreground hover:text-destructive transition leading-none px-1.5 text-lg shrink-0"
>
×
</button>
</div>
))}
<div className="flex items-center justify-between pl-6">
<Button
type="button"
variant="ghost"
size="sm"
onClick={addRow}
disabled={full}
className="font-mono text-[11px] tracking-wider uppercase h-7 px-2"
>
+ Add member
</Button>
<span className="font-mono text-[10px] text-muted-foreground tracking-wider uppercase">
{filled} / {MAX_MEMBERS}
</span>
</div>
</div>
)
}
+25 -5
View File
@@ -1,7 +1,20 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { useLocalMode } from '@/lib/useLocalMode'
import { cn } from '@/lib/utils' 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 { export interface OpenYourNodeProps {
/** 'hero' is the big primary CTA used on the setup page; 'inline' is a compact /** 'hero' is the big primary CTA used on the setup page; 'inline' is a compact
* link for reuse inside later module pages. */ * link for reuse inside later module pages. */
@@ -16,7 +29,9 @@ export interface OpenYourNodeProps {
*/ */
export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps) { export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps) {
const device = useSession((s) => s.device) 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) { if (!canOpen) {
return ( return (
@@ -34,7 +49,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
if (variant === 'inline') { if (variant === 'inline') {
return ( return (
<a <a
href={device.nodeUrl!} href={nodeHref!}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(
@@ -49,7 +64,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
return ( return (
<a <a
href={device.nodeUrl!} href={nodeHref!}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(
@@ -58,9 +73,14 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
)} )}
> >
<div> <div>
<div className="text-xl font-bold tracking-tight">Open your node →</div> <div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight">Open your node →</span>
<span className="font-mono text-[9px] uppercase tracking-widest text-teal border border-teal/40 bg-teal/10 rounded px-1.5 py-0.5">
Connected
</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs"> <div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
{device.nodeUrl} {device.port ? `${device.port} · ` : ''}{nodeHref}
</div> </div>
</div> </div>
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden /> <span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
+15 -5
View File
@@ -24,10 +24,10 @@ describe('PhaseStrip', () => {
expect(phases).toHaveLength(5) expect(phases).toHaveLength(5)
expect(phases.map((p) => p.textContent)).toEqual([ expect(phases.map((p) => p.textContent)).toEqual([
expect.stringContaining('Team reg'), expect.stringContaining('Team reg'),
expect.stringContaining('Env setup'), expect.stringContaining('Meet your agent'),
expect.stringContaining('Module 1'), expect.stringContaining('Module 1'),
expect.stringContaining('Module 2'), expect.stringContaining('Module 2'),
expect.stringContaining('ADD · submit'), expect.stringContaining('Module 3'),
]) ])
}) })
@@ -42,17 +42,27 @@ describe('PhaseStrip', () => {
useSession.getState().completePhase('setup') useSession.getState().completePhase('setup')
renderAt('/workshop/module1', 'm1') renderAt('/workshop/module1', 'm1')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('data-state', 'done') expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('data-state', 'done')
expect(screen.getByText('Env setup').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 1').closest('a')).toHaveAttribute('data-state', 'active')
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('data-state', 'pending') 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', () => { it('links each phase to its workshop sub-route', () => {
renderAt('/workshop') renderAt('/workshop')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('href', '/workshop') expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('href', '/workshop')
expect(screen.getByText('Env setup').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 1').closest('a')).toHaveAttribute('href', '/workshop/module1')
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('href', '/workshop/module2') expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('href', '/workshop/module2')
expect(screen.getByText('ADD · submit').closest('a')).toHaveAttribute('href', '/workshop/add') expect(screen.getByText('Module 3').closest('a')).toHaveAttribute('href', '/workshop/add')
}) })
}) })
+9 -3
View File
@@ -10,10 +10,10 @@ interface PhaseMeta {
const PHASES: PhaseMeta[] = [ const PHASES: PhaseMeta[] = [
{ key: 'reg', name: 'Team reg', to: '/workshop' }, { key: 'reg', name: 'Team reg', to: '/workshop' },
{ key: 'setup', name: 'Env setup', to: '/workshop/setup' }, { key: 'setup', name: 'Meet your agent', to: '/workshop/setup' },
{ key: 'm1', name: 'Module 1', to: '/workshop/module1' }, { key: 'm1', name: 'Module 1', to: '/workshop/module1' },
{ key: 'm2', name: 'Module 2', to: '/workshop/module2' }, { key: 'm2', name: 'Module 2', to: '/workshop/module2' },
{ key: 'add', name: 'ADD · submit', to: '/workshop/add' }, { key: 'add', name: 'Module 3', to: '/workshop/add' },
] ]
export interface PhaseStripProps { export interface PhaseStripProps {
@@ -22,10 +22,16 @@ export interface PhaseStripProps {
export function PhaseStrip({ active }: PhaseStripProps) { export function PhaseStrip({ active }: PhaseStripProps) {
const phases = useSession((s) => s.phases) 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 ( 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"> <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) => { {PHASES.map((p, i) => {
const done = phases[p.key] const done = activeIndex >= 0 ? i < activeIndex : phases[p.key]
const isActive = active === p.key const isActive = active === p.key
return ( return (
<NavLink <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')) await user.click(screen.getByText('Bravo'))
expect(onSelect).toHaveBeenCalledWith('b') 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> <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>} {s.scored && <Badge className="font-mono text-[8px] uppercase tracking-wider">scored</Badge>}
</div> </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> </button>
))} ))}
</div> </div>
+13 -5
View File
@@ -17,12 +17,18 @@ export interface TeamCardProps {
team: TeamSnapshot team: TeamSnapshot
submitted: boolean submitted: boolean
scored: boolean scored: boolean
/**
* Real per-team tallies from the node activity stream (useCollective.counts).
* `team.stats` is NOT used here: nothing has called recordEvent since the
* simulator was removed, so those counters are permanently zero.
*/
counts?: { calls: number; flashes: number; errors: number }
/** current wall-clock ms; 0 (default) disables stale dimming */ /** current wall-clock ms; 0 (default) disables stale dimming */
now?: number now?: number
} }
/** One team's live tile in the instructor grid. Pure presentational. */ /** One team's live tile in the instructor grid. Pure presentational. */
export function TeamCard({ team, submitted, scored, now = 0 }: TeamCardProps) { export function TeamCard({ team, submitted, scored, counts, now = 0 }: TeamCardProps) {
const stale = now > 0 && now - new Date(team.updatedAt).getTime() > STALE_MS const stale = now > 0 && now - new Date(team.updatedAt).getTime() > STALE_MS
return ( return (
<div <div
@@ -57,10 +63,12 @@ export function TeamCard({ team, submitted, scored, now = 0 }: TeamCardProps) {
))} ))}
</div> </div>
<div className="font-mono text-[10px] text-muted-foreground tabular-nums"> <div
{team.stats.calls} · <span className="text-teal">{team.stats.nominal}</span>{' '} className="font-mono text-[10px] text-muted-foreground tabular-nums"
<span className="text-amber">{team.stats.anomalous}</span>{' '} title="agent runs · flashes · errors"
<span className="text-rose">{team.stats.critical}</span> >
{counts?.calls ?? 0} runs · <span className="text-primary">{counts?.flashes ?? 0}</span> flashed{' '}
{(counts?.errors ?? 0) > 0 && <span className="text-destructive">{counts?.errors} err</span>}
</div> </div>
{(submitted || scored) && ( {(submitted || scored) && (
+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>
)
}

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