Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ The authoritative detailed design is
FS/SQLite mounts. There is no separate persistent App Catalog service.
- `tools/xtask` packages ordinary Apps without moving their product logic into
the AgentOS runtime or host adapters.
- HTTP is the current package ingress. It hands the complete `.pocketapp` to the
Installer, which alone validates, stores credentials, creates `current` and
activates runtime metadata. A future UART ingress must use the same boundary.
- HTTP and UART are package ingress adapters. Both hand the complete `.pocketapp`
to the Installer, which alone validates, stores credentials, creates `current`
and activates runtime metadata.
- `crates/pocket-pi-embedded` provides the bounded JavaScript Agent Loop bridge.
In AgentOS hosts, the loop is loaded from the Pi Agent System App release into
the same PocketJS Guest as its Root View.
Expand Down Expand Up @@ -59,6 +59,11 @@ LRU caches. Only the foreground View ticks or renders. On ESP32-P4, App QuickJS
heaps and large worker stacks allocate explicitly from PSRAM without changing
the platform-wide `malloc()` policy.

Uninstall is the reverse of ordinary App activation inside the same
`AppSupervisor`: it removes the App's Tool routes, schedules, cached View/Data
Action Guests, native credentials/session state and complete App data root. It
does not introduce a second lifecycle manager or affect the resident Pi Agent.

Ordinary Apps receive capability-scoped data roots. Pi Agent alone owns the
top-level `/workspace` and cross-App Tool Registry.

Expand All @@ -72,8 +77,11 @@ crates/pocket-pi-tools/ native workspace/shell/time/schedule Tools
crates/pocket-pi-protocols/ provider codecs
hosts/esp32-p4-sim/ macOS development simulator for ESP32-P4 contracts
firmware/esp32-p4/ first supported target and reference implementation
tools/uart_bridge/ Mac Codex/Claude streaming adapters
tools/uart-model-bridge.py UART framing and provisioning CLI
tools/uart_io.py shared raw UART read/write helpers
tools/uart-provision.py one-time wireless model provisioning
tools/uart-install.py App package ingress over UART
tools/uart_bridge/ development-only Codex/Claude adapters
tools/uart-model-bridge.py optional development-only model bridge
```

## Build entry points
Expand Down
106 changes: 69 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,47 +92,65 @@ espflash flash --baud 921600 --port "$DEVICE_PORT" \
firmware/esp32-p4/target/riscv32imafc-esp-espidf/release/pocket-pi-p4
```

For development, the simplest model path is UART to a logged-in Mac Codex:
Provision the board once with a direct wireless model backend. DeepSeek is the
default provider and uses `deepseek-v4-flash` unless `--model` is supplied:

```sh
python3 tools/uart-model-bridge.py "$DEVICE_PORT" \
--backend uart --provider codex
python3 tools/uart-provision.py "$DEVICE_PORT" \
--provider deepseek --provision-wifi
```

Claude Code can be used instead:
The command reads the DeepSeek key from macOS Keychain when available, otherwise
it prompts without echoing the value. It stores model provider, model, thinking
level and API key in native NVS. Wi-Fi credentials use the existing Wi-Fi NVS
store and can later be changed from Settings. Subsequent boots load both stores
directly and do not wait for a Mac or UART bridge.

Other direct providers are selected explicitly:

```sh
python3 tools/uart-model-bridge.py "$DEVICE_PORT" \
--backend uart --provider claude-code
python3 tools/uart-provision.py "$DEVICE_PORT" \
--provider openai --model gpt-5-mini
```

The bridge can also send a repeatable boot prompt:
For bring-up on an unprovisioned development board only, the optional model
bridge can route requests to a logged-in Mac Codex or Claude Code. It is not
part of standalone startup and is never stored as the device backend:

```sh
python3 tools/uart-model-bridge.py "$DEVICE_PORT" \
--backend uart --provider codex \
python3 tools/uart-model-bridge.py "$DEVICE_PORT" --provider codex \
--prompt 'Use write, read, schedule.set and schedule.list.'
```

For standalone use, provision Wi-Fi and a direct model provider over UART:
Use `espflash monitor` when only serial logs are needed:

```sh
python3 tools/uart-model-bridge.py "$DEVICE_PORT" \
--backend wireless --provider openai --model gpt-5-mini --provision-wifi
espflash monitor --port "$DEVICE_PORT"
```

The provisioning command asks for the Wi-Fi credentials and API key
interactively. Wi-Fi can subsequently be changed from the device Settings UI
without reflashing. Credentials are kept out of Agent workspace files and
PocketJS UI state.
To install over the local network, open `http://<device-ip>/` from a computer or
phone and upload the `.pocketapp`. When local peer access is unavailable, upload
the same artifact over USB UART:

To install an ordinary App, open `http://<device-ip>/` from a computer or phone,
upload its `.pocketapp`, then confirm on the Pocket Pi screen. Every credential
declared by the App must be present in the package. Installer removes those values
from the package and stores them in native NVS; they are not exposed through the
App filesystem or Agent workspace. HTTP is the current upload ingress; activation
always goes through the same Installer. A future UART ingress may deliver the same
artifact, but may not write App storage, credentials or runtime state directly.
```sh
python3 tools/uart-install.py "$DEVICE_PORT" \
target/pocketapps/exa.pocketapp
```

Both transports stop at the same review screen; installation starts only after
confirmation on Pocket Pi. Every credential declared by the App must be present
in the package. Installer removes those values from the package and stores them in
native NVS; they are not exposed through the App filesystem or Agent workspace.
Neither HTTP nor UART writes App storage, credentials or runtime state directly.
UART upload does not reset the board or change its model configuration.
The UART CLIs leave DTR/RTS inactive before closing the port so the USB serial
bridge does not reset a running Pocket Pi.

To remove an ordinary App, open **Apps**, tap **UNINSTALL APP**, then tap the
`X` on that App's row. Uninstall removes the App release, SQLite/data files,
schedules, credentials, native session state and cached View/Data Action
Runtimes. It does not retain App data or provide rollback. The resident Pi Agent
System App cannot be uninstalled.

### 2. Develop with the ESP32-P4 simulator on macOS

Expand Down Expand Up @@ -177,12 +195,13 @@ Backends belong to their host composition, not to the Agent core:
| Host | Supported backends |
|---|---|
| ESP32 simulator | local Codex, OpenAI, OpenRouter, Anthropic, DeepSeek V4 |
| Physical ESP32-P4 | UART to Mac Codex or Claude Code; wireless OpenAI, OpenRouter, Anthropic or DeepSeek V4 |
| Physical ESP32-P4 | standalone wireless OpenAI, OpenRouter, Anthropic or DeepSeek V4 |

`UartBackend` and `WirelessBackend` implement the same model-completion
contract. Wireless providers may emit progress events internally; the current
UART bridge coalesces provider chunks into one final framed result before it
reaches the device. Provider request/streaming codecs live in
The optional development-only `UartBackend` and the standalone
`WirelessBackend` implement the same model-completion contract. Wireless
providers may emit progress events internally; the development bridge coalesces
provider chunks into one final framed result before it reaches an unprovisioned
device. Provider request/streaming codecs live in
`pocket-pi-protocols`; serial framing, desktop CLIs and ESP-IDF HTTPS stay in
their platform layers.

Expand Down Expand Up @@ -216,6 +235,7 @@ includes:
- Chat with provider-dependent incremental replies, recent-turn history and a
full-message reader;
- Files with workspace metadata, file viewing and scrolling;
- Apps with install discovery and destructive uninstall mode;
- Settings with Wi-Fi scanning, selection and password entry;
- touch keyboard and next-schedule status.

Expand All @@ -233,8 +253,11 @@ crates/pocket-pi-protocols/ model request, response and streaming codecs
crates/pocket-pi-agentos/ App Supervisor, System App lifecycle and App contracts
hosts/esp32-p4-sim/ macOS development simulator for ESP32-P4 contracts
firmware/esp32-p4/ first supported device and reference implementation
tools/uart_bridge/ Mac Codex and Claude Code streaming adapters
tools/uart-model-bridge.py UART framing and provisioning CLI
tools/uart_io.py shared raw UART read/write helpers
tools/uart-provision.py one-time wireless model provisioning
tools/uart-install.py App package ingress over UART
tools/uart_bridge/ development-only Codex/Claude adapters
tools/uart-model-bridge.py optional development-only model bridge
```

Dependencies point inward: hosts depend on shared runtimes, tools, UI and
Expand All @@ -247,16 +270,25 @@ and [docs/esp32-p4-port.md](docs/esp32-p4-port.md) for board-specific details.

## Current validation

On **2026-08-14**, the workspace completed 34 Rust tests and 3 App text behavior
On **2026-08-14**, the workspace completed 35 Rust tests and 3 App text behavior
tests with no failures, passed workspace Clippy with warnings denied, and built
the ESP32-P4 release firmware. A simulator LAN smoke uploaded the generated Exa
package through `POST /install` and received HTTP 202; the core install test
then proves activation, Tool routing, SQLite ownership and restart recovery for
a previously absent App.

Fresh physical flash/boot, phone upload, Wi-Fi/DHCP, provider calls and
unattended memory pressure remain separate evidence tiers. Simulator and
cross-build success do not substitute for fresh physical-board acceptance.
proves activation, Tool routing, SQLite ownership and restart recovery for a
previously absent App. The uninstall lifecycle test proves complete App-owned
state removal, live Tool removal, restart absence and reinstall. A physical
ESP32-P4 then accepted the same Exa package over UART, stopped at the shared
review screen, installed after touch confirmation, uninstalled it from Apps,
kept it absent across restart, and accepted a clean UART reinstall.
The split UART tools were then verified on the same board: one-time DeepSeek
provisioning received `PPI-CONFIG-STORED`; a bridge-free reset logged
`loaded wireless model configuration from NVS`, restored Wi-Fi/DHCP on
the saved network, and reached an idle Pi Agent; the independent App uploader
transferred Exa to the shared review screen without resetting the board.

Phone upload, fresh provider calls and unattended memory pressure remain
separate evidence tiers. Simulator and cross-build success do not substitute
for fresh physical-board acceptance.

## Development checks

Expand Down
59 changes: 40 additions & 19 deletions apps/pi-agent/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type Projection = {
schedule?: { name?: string | null; prompt?: string; next?: string; everyMinutes?: number | null };
apps?: InstalledApp[];
install?: InstallProjection | null;
uninstallingApp?: string | null;
uninstallError?: string | null;
settings?: {
wifi?: {
connectedSsid?: string | null;
Expand Down Expand Up @@ -68,6 +70,7 @@ const [projection, setProjection] = createSignal<Projection>({
messages: [{ role: "assistant", text: "BOOTING PI AGENT..." }],
});
const [installDetail, setInstallDetail] = createSignal<InstallProjection | null>(null);
const [uninstallMode, setUninstallMode] = createSignal(false);
const [screen, setScreen] = createSignal<Screen>("chat");
const [activeTab, setActiveTab] = createSignal<Tab>("chat");
const [chatScroll, setChatScroll] = createSignal(0);
Expand All @@ -84,7 +87,6 @@ const [uppercase, setUppercase] = createSignal(false);
const [keyboardPurpose, setKeyboardPurpose] = createSignal<KeyboardPurpose>({ type: "prompt" });
const [pressedKey, setPressedKey] = createSignal<string | null>(null);
const [wifiOffset, setWifiOffset] = createSignal(0);
const fileCache = new Map<string, FileEntry[]>();

const letterRows = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];
const numberRows = ["1234567890", "-/:;()$&@", ".,?!'\"+"];
Expand Down Expand Up @@ -126,21 +128,13 @@ function moveViewerPage(direction: -1 | 1) {
setViewer({ ...current, pageIndex: nextIndex, page: filePage(current.text, start) });
}

function refreshFiles(path = filePath(), force = false) {
const cached = fileCache.get(path);
if (!force && cached) {
setFiles(cached);
setFileError("");
setFileOffset((value) => Math.min(value, Math.max(0, cached.length - 8)));
return;
}
function refreshFiles(path = filePath()) {
try {
const next = (readdirSync(path, { withFileTypes: true }) as DirEntry[]).map((entry) => ({
name: entry.name,
kind: entry.isDirectory() ? "dir" as const : "file" as const,
size: entry.size,
}));
fileCache.set(path, next);
setFiles(next);
setFileError("");
setFileOffset((value) => Math.min(value, Math.max(0, next.length - 8)));
Expand Down Expand Up @@ -285,10 +279,16 @@ function FilesScreen() {

function AppsScreen() {
const apps = () => projection().apps ?? [];
const uninstalling = () => projection().uninstallingApp;
const status = () => projection().uninstallError
? "UNINSTALL FAILED · " + projection().uninstallError
: uninstalling()
? "UNINSTALLING " + uninstalling()!.toUpperCase() + "..."
: "APP DATA STAYS ISOLATED. PI AGENT CAN USE EACH APP'S TOOLS.";
return (
<View class="flex-col w-full h-full bg-slate-50">
<Header title="APPS" />
<View class="h-[1060] px-6 pt-7 flex-col gap-4">
<View class="relative h-[1060] px-6 pt-7 flex-col gap-4">
<Text class="text-base text-slate-500 font-bold">{String(apps().length) + " INSTALLED APPS"}</Text>
<Show when={apps().length > 0} fallback={
<View class="h-[214] px-7 items-center justify-center bg-slate-100"><Text class="text-lg text-slate-500 font-bold">NO OPTIONAL APPS INSTALLED</Text></View>
Expand All @@ -297,14 +297,23 @@ function AppsScreen() {
<View class="w-[672] h-[150] px-6 flex-row items-center justify-between bg-white">
<View class="flex-row items-center gap-5">
<View class="w-[68] h-[68] items-center justify-center bg-orange-100"><Text class="text-xl text-orange-700 font-bold">{app.title.slice(0, 1).toUpperCase()}</Text></View>
<View class="w-[500] flex-col gap-2"><Text class="text-xl text-slate-900 font-bold">{app.title}</Text><Text class="text-lg text-slate-600">{app.description}</Text><Show when={app.scheduleEveryMinutes}><Text class="text-base text-slate-500">{"UPDATES EVERY " + String(app.scheduleEveryMinutes) + " MINUTES"}</Text></Show></View>
<View class="w-[448] flex-col gap-2"><Text class="text-xl text-slate-900 font-bold">{app.title}</Text><Text class="text-lg text-slate-600">{app.description}</Text><Show when={app.scheduleEveryMinutes}><Text class="text-base text-slate-500">{"UPDATES EVERY " + String(app.scheduleEveryMinutes) + " MINUTES"}</Text></Show></View>
</View>
<Text class="text-2xl text-orange-600">›</Text>
<Show when={uninstallMode()} fallback={<Text class="text-2xl text-orange-600">›</Text>}>
<View class="w-[68] h-[68]"><ActionButton label={uninstalling() === app.id ? "..." : "X"} disabled={Boolean(uninstalling())} tone="danger" /></View>
</Show>
</View>
)}</For>
</Show>
<View class="mt-4 h-[112] px-6 justify-center bg-slate-100">
<Text class="text-base text-slate-600">{"APP DATA STAYS ISOLATED.\nPI AGENT CAN USE EACH APP'S TOOLS."}</Text>
<View class="absolute left-[24] top-[820] w-[672] h-[112] px-6 bg-slate-100">
<StatusBar text={status()} tone={projection().uninstallError ? "danger" : "neutral"} />
</View>
<View class="absolute left-[24] top-[948] w-[672] h-[80]">
<ActionButton
label={uninstallMode() ? "DONE" : "UNINSTALL APP"}
disabled={(!uninstallMode() && apps().length === 0) || Boolean(uninstalling())}
tone={uninstallMode() ? "neutral" : "danger"}
/>
</View>
</View>
<BottomBar />
Expand Down Expand Up @@ -522,9 +531,10 @@ function Root() {

function openTab(tab: Tab) {
batch(() => {
if (tab !== "apps") setUninstallMode(false);
setActiveTab(tab);
setScreen(tab);
if (tab === "files") refreshFiles();
if (tab === "files") refreshFiles(filePath());
});
}

Expand Down Expand Up @@ -600,7 +610,7 @@ function handleKeyboardTap(x: number, y: number): string {
}

mount(() => <Root />);
queueMicrotask(() => refreshFiles("", true));
queueMicrotask(() => refreshFiles(""));

(globalThis as any).PocketPiApp = {
tick() {
Expand Down Expand Up @@ -630,6 +640,7 @@ queueMicrotask(() => refreshFiles("", true));
}
return "";
}
if (projection().uninstallingApp) return "";
if (screen() === "keyboard") return handleKeyboardTap(x, y);
if (screen() === "viewer") {
const current = viewer();
Expand Down Expand Up @@ -713,8 +724,18 @@ queueMicrotask(() => refreshFiles("", true));
return "";
}
if (screen() === "apps") {
const app = (projection().apps ?? [])[Math.floor((y - 162) / 166)];
if (y >= 162 && app) return JSON.stringify({ type: "navigate", app: app.id });
const apps = projection().apps ?? [];
if (y >= 1060 && y < 1140 && (uninstallMode() || apps.length > 0) && !projection().uninstallingApp) {
setUninstallMode(!uninstallMode());
return "";
}
const app = apps[Math.floor((y - 162) / 166)];
if (y >= 162 && app && !projection().uninstallingApp) {
if (uninstallMode()) {
return x >= 604 && x < 696 ? JSON.stringify({ type: "uninstallApp", app: app.id }) : "";
}
return JSON.stringify({ type: "navigate", app: app.id });
}
return "";
}
if (screen() === "settings") {
Expand Down
23 changes: 11 additions & 12 deletions apps/pi-agent/dist/app.js

Large diffs are not rendered by default.

Binary file modified apps/pi-agent/dist/app.pak
Binary file not shown.
Loading
Loading