# AO_AppMgr

The Active Object that owns the apps region: installs an app from a file, a URL, the console or the mailbox, runs and erases apps, updates kodeOS.

## What it is

An app on a Kode Dot is a whole firmware image, written into the **apps region**, a part of the flash. **AO_AppMgr** owns that region. It installs an app, starts an installed app by name, and erases one by name. It also writes the inactive kodeOS slot, which is how `install dst=os` updates kodeOS.

It reads no file itself: AO_Storage reads and lends the bytes in chunks (`sdk/kaos/storage`). It does not decide which firmware starts either. AO_Boot does (`sdk/kaos/boot`), and the two AOs write different regions of the flash.

An image can come from four places: a file on a medium, a URL, the console, or the **mailbox**, the `app_pending` partition that a computer writes with esptool. Every install ends the same way. The directory, the list of installed apps, is updated in one commit after the written image is verified. When an install must evict apps to make room, AO_AppMgr first copies each victim to the card, then commits the eviction, then erases.

## How it is connected

```mermaid
flowchart LR
  file["File on a medium"] --> sto["AO_Storage"]
  url["URL"] --> net["AO_Net"]
  sto --> ao["AO_AppMgr"]
  net --> ao
  con["Console"] --> ao
  mbx["Mailbox<br/>app_pending"] --> ao
  ao -- "install, erase" --> region["Apps region"]
  ao -- "install dst=os" --> slot["Inactive<br/>kodeOS slot"]
  ao -- "run request" --> boot["AO_Boot"]
  class ao,sto,net,boot ao
  class region,slot,mbx store
```

*Where an image comes from and where it goes*

```mermaid
stateDiagram-v2
  [*] --> idle : claim, kdrv_apps_init, mailbox check
  idle --> opening : FLASH_REQ_SIG from a file, card or URL
  opening --> flashing : source open, both buffers primed
  flashing --> idle : eof verified and installed, or a failure
  idle --> receiving : APP_RECV_REQ_SIG, console armed first
  receiving --> launching : installed, run requested
  idle --> adopting : APP_ADOPT_SIG, mailbox holds an image
  flashing --> keeping : the plan evicts apps the card lacks
  adopting --> keeping : the same, for the mailbox
  keeping --> idle : copies done, the request is posted again
  idle --> asking : APP_EVICT_REQ_SIG
  launching --> idle : APP_RECV_LAUNCH_SIG
```

*AO_AppMgr's states*

```mermaid
sequenceDiagram
  autonumber
  box "kodeOS"
    participant A as AO_AppMgr
    participant S as AO_Storage
  end
  A->>S: STORAGE_OPEN_REQ_SIG
  S-->>A: STORAGE_OPEN_RSP_SIG, size
  A->>S: READ_REQ x2, both buffers
  S-->>A: STORAGE_DATA_SIG, buffer 0
  Note over A: writes chunk N to flash while S fills buffer 1
  A->>S: STORAGE_NEXT_SIG, buffer 0 back
```

*An install from a file, two buffers*

AO_AppMgr in numbers:

| Thing | Value |
| --- | --- |
| Priority | 9 |
| Queue | 16 events |
| Stack | 8192 bytes, the default tier |
| Claims | `KBSP_RES_APPS_REGION`, `KBSP_RES_APP_PENDING`, `KBSP_RES_OTA_SLOT` |
| Compiled under | `KODEOS_APPMGR` |
| Flash access | `kdrv_apps`, `kdrv_slot` and `kdrv_kfw` (`sdk/kdrv/apps`) |

The stack is that large because the install path holds two directory copies of about 1 KB on it, and at 4 KB a streamed install overflowed and hung with no panic.

### The hierarchy

`idle` is the only state that accepts work. Every other working state sits inside a parent, `AppMgr_busy`, which answers a question about the next install ("what would you evict?") with a busy end marker and settles late answers from AO_Storage. A new write, run, erase or list request that arrives in a working state is refused and never queued, so two images cannot mix in one place of the flash.

`opening`, `flashing`, `adopting` and `keeping` log the refusal. `receiving` and `launching` ignore it silently. `app_cat` is the exception: the console waits for its answer, so a refused one is answered with `KODE_DENIED`.

### The copy

`FLASH_REQ_SIG` carries three things:

- a source kind: `KFW_SRC_FS`, `_SD`, `_HTTP` or `_PENDING`;
- a destination: `KFW_TARGET_APP`, `_OS`, or `KODE_FLASH_DST_PENDING`, the mailbox;
- a `launch` flag.

A URL moves the whole conversation from AO_Storage to the stream loan of AO_Net; the protocol is the same. Each chunk goes to the decoder of `kdrv_kfw`, which tells a bare `0xE9` image from a `KFW1` container and inflates compressed ones, so progress counts the image and not the bytes that carried it. A URL holds up to 256 characters and travels whole. The progress events and the log of AO_AppMgr name it by its last path segment, and no log line prints its query, where a token usually rides. A file name holds up to 63.

The image is placed in the decoder's header callback, the only moment its true length is known. For `dst=app` that is `kdrv_apps_plan()` on a copy of the directory, told which installed app the header names. When there is no room it evicts that app's old copy first, then the oldest apps by sequence number, so a new version costs no other app while it fits the room of the old one. For `dst=os` it is a size check against the slot, and `launch` is forced off because the switch of AO_Boot is the launch. `dst=c5` never reaches AO_AppMgr: the console sends it to AO_Radio.

### The pacer

After a chunk is written, AO_AppMgr waits one 10 ms tick before it hands the buffer back. Erasing flash holds the cache down and nothing in flash runs, the GUI included, so the gap gives the interface a slot.

The returned buffers collect in the `to_return` bitmask and one pacer returns them all. Arming it once per buffer would produce two "next" requests for one loan, AO_Storage would refill the same buffer twice, and the image would be corrupt with a byte count that still adds up. The gap costs about 1.6 s on a 5 MB image.

### Verify, then commit

`xfer_end()` verifies the written extent in place with the real image verifier. For `dst=app`, `region_install()` then runs these steps:

1. Zero the entry of an app with the same name (installing replaces).
2. Take the next sequence number, before compacting.
3. Compact the directory.
4. Evict the oldest entry when the directory holds `KAPPS_MAX_ENTRIES` (64).
5. Append the new entry.
6. Make one `kdrv_apps_dir_commit()`.

AO_AppMgr then publishes `APP_FLASH_DONE_SIG` with the project name. When `launch` is set, `region_launch()` resolves the extent out of the committed directory by name, the same path `APP_RUN_REQ_SIG` takes, and asks AO_Boot for a one-shot run.

### Keeping an evicted app

A victim that came from a URL, the console, the mailbox or the internal FAT has no other copy. Before AO_AppMgr commits an eviction it copies each victim to `/sdcard/<project>.bin`, byte for byte. The extent is a verified, bootable image with its own manifest, so the launcher shows the file as an app to install again. A victim is already kept when that file exists, whatever its content.

The install runs in a round. The first plan that names victims abandons the transfer and enters `keeping`, which checks the card with a STAT and, if the file is absent, reads the extent from the region into PSRAM 32 KB per tick and hands it to AO_Storage as a whole-file write. When every victim is tried, AO_AppMgr posts the same request again at the front of its queue, and the second plan only evicts.

A failed copy decides by origin:

- From the internal FAT, a URL or the mailbox, the install goes ahead with a warning.
- From the card, it is cancelled before anything is evicted, with `APP_FLASH_FAIL_SIG` and reason `KFAIL_KEEP`. A failed write on a counterfeit card poisons later reads, and both apps would be lost.
- From the console, nothing is copied.

### Install from the console

`install src="terminal"` is its own request, `APP_RECV_REQ_SIG`. In `idle` AO_AppMgr checks that the region or the slot exists and fits, then asks AO_Log for raw mode first, before any flash work, because an erase stops the reader task and an arming that lands inside it cannot tell the command's newline from the first payload byte.

In `receiving`, the transfer runs in four steps:

1. AO_AppMgr makes room, opens the sink, tightens the watchdog from the 30 s long-stop to the 5 s gap and publishes `RECV_READY_SIG`.
2. The host sends 4096-byte chunks (`RECV_DATA_SIG`, posted by the reader task).
3. AO_AppMgr stages each chunk, takes its CRC, writes it, and publishes `RECV_ACK_SIG` for each whole chunk.
4. At the full count it compares the CRC, verifies, installs and publishes one of three static outcome events: `RECV_DONE_SIG`, `RECV_FAIL_SIG` or `RECV_STALL_SIG`.

The room is made before the first byte arrives, so the plan cannot read the image's name. The request carries it instead, from `install … name="<project>"`, which kcli and kble send, and the old copy of that app goes first. Without `name` the plan treats the image as a new app. Each app evicted is published as `APP_EVICTED_SIG` before `RECV_READY_SIG`, and the console prints it as `>|evicted`.

The acks are the entire flow control, because the USB driver drops bytes silently when its ring is full. A failed write stops neither the draining nor the acks, since the host still owes the full count.

A failure after the 0 % report also publishes `APP_FLASH_FAIL_SIG`, so the Opening card ends as a failure. The `launching` state waits 50 ms so the `>ok` leaves the console before the board resets.

### Mailbox adoption

At boot, and for `install src="pending"`, AO_AppMgr checks `kdrv_apps_pending_present()` and posts `APP_ADOPT_SIG` to itself, because a copy of several MB must not run inside an initial transition. It reads the destination from the OS-intent sector (unless `dst=` was typed), reads the project name from the head of the mailbox, plans with it, and copies 4 KB per tick in `adopting`.

A deterministic failure (the image does not verify, the directory is full and nothing can be evicted) consumes the mailbox so no later boot adopts it again. A directory commit failure or an I/O error leaves it, and the next boot tries again. On success the mailbox is consumed after the commit.

A boot-time adoption then starts the app with `KBOOT_RUN_STICKY`, the one sticky request kodeOS writes, so the app survives the reset that the host's port opening causes. A typed `install src="pending"` starts it once, and only with `run=on`.

### Resting with the board

With `CONFIG_KODE_LOWPOWER`, AO_AppMgr joins the rounds of AO_LowPower as `appmgr` (`sdk/kaos/lowpower`). It owns no device that sleeps. It owns work that must not be cut: an image being written, kept or received, a launch on its way and the asks of an eviction are every state but `idle`. Each of those answers a round busy (`app write`), and `idle` answers ready. Nothing is restored when the board wakes.

## Specifications

### Requests

| Signal | Event | From | What AO_AppMgr does |
| --- | --- | --- | --- |
| `FLASH_REQ_SIG` | `FlashReqEvt` (`src_kind`, `dst_kind`, `launch`, `src`) | `kode_flash()`, the console, the launcher | The only way to ask for a copy. `appmgr_flash_request(file, launch)` is the form `src="fs:…" dst=app` |
| `APP_RECV_REQ_SIG` | `AppRecvReqEvt` (`dst`, `size`, `crc32`, `launch`, `src`, `name`) | `appmgr_recv_request()` | An image arrives on the console |
| `APP_RUN_REQ_SIG` | `AppFlashEvt` (`file`) | `appmgr_run_request()` | Resolves the name in the directory and asks AO_Boot for a one-shot run. Logs `'<name>' is not installed` otherwise |
| `APP_ERASE_REQ_SIG` | `AppFlashEvt` | `appmgr_erase_request()` | Zeroes the entry, compacts, commits, then `kdrv_apps_erase_header()` clears the first 4 KB of the extent |
| `APP_CAT_REQ_SIG` | `AppCatReqEvt` (`project`, `cats`, `clear`) | `appmgr_cat_request()` | Sets or clears the category override in NVS. AO_AppMgr is the only writer of that table |
| `APP_LIST_REQ_SIG` | `AppListReqEvt` (`skip`, `who`) | `appmgr_list_page()` | Posts up to 24 `APP_ENTRY_SIG` and an end marker to `who` |
| `APP_EVICT_REQ_SIG` | `AppEvictReqEvt` (`size`, `project`, `who`) | `appmgr_evict_query()` | Plans an install on a copy of the directory and posts up to 8 `APP_EVICT_ENTRY_SIG`, then `APP_EVICT_END_SIG` (`victims`, `fits`, `busy`) |
| `STORAGE_OPEN_RSP_SIG`, `STORAGE_DATA_SIG`, `NET_STREAM_OPEN_RSP_SIG`, `NET_STREAM_DATA_SIG` | Storage and stream events | AO_Storage, AO_Net | The loan |
| `STORAGE_PATH_RSP_SIG`, `STORAGE_WRITE_RSP_SIG` | `StoragePathRspEvt`, `StorageWriteRspEvt` | AO_Storage | The answers of `keeping` and `asking` |
| `CONSOLE_RAW_RSP_SIG`, `RECV_DATA_SIG` | `ConsoleRawRspEvt`, `RecvDataEvt` | AO_Log, the reader task | The console install |
| `LOWPOWER_ROUND_SIG` | `LowPowerRoundEvt` | AO_LowPower, subscribed with `CONFIG_KODE_LOWPOWER` | Answers ready in `idle`, busy `app write` in every other state |

Private: `APP_ADOPT_SIG`, and the `QTimeEvt`s `APP_FLASH_CHUNK_SIG`, `APP_RECV_IDLE_SIG` and `APP_RECV_LAUNCH_SIG`.

### What it sends

| Signal | Event | Range | Meaning |
| --- | --- | --- | --- |
| `APP_FLASH_PROGRESS_SIG` | `AppProgressEvt` (`percent`, `what`, `file`) | publish | 0 % when a write starts, then each whole percent. `what` is `KWRITE_APP`, `_APP_RUN`, `_OS` or `_C5`. The Opening card and the LED pulse of AO_Power follow it. The log keeps a 10 % step |
| `APP_FLASH_DONE_SIG` | `AppFlashDoneEvt` (`name`) | publish | The app installed; `name` is empty when the descriptor could not be read, and a static event goes out when the pool is dry |
| `APP_EVICTED_SIG` | `AppEvictedEvt` (`name`, `kept`) | publish | One per app an install evicts, before the write starts. `kept` says a copy is on the card. The old copy of the app being replaced is not one |
| `APP_FLASH_FAIL_SIG` | `AppFlashFailEvt` (`why`) | publish | `KFAIL_WRITE`, or `KFAIL_KEEP` when the card could not keep an app. One static event per reason |
| `APP_CAT_RSP_SIG` | `AppCatRspEvt` | publish | The outcome of `APP_CAT_REQ_SIG` |
| `RECV_READY_SIG`, `RECV_ACK_SIG`, `RECV_DONE_SIG`, `RECV_FAIL_SIG`, `RECV_STALL_SIG` | none | publish | The receive protocol. The three outcomes are static and cannot fail to allocate |
| `APP_ENTRY_SIG`, `APP_LIST_END_SIG`, `APP_EVICT_ENTRY_SIG`, `APP_EVICT_END_SIG` | `AppEntryEvt`, `ListEndEvt`, `AppEvictEntryEvt`, `AppEvictEndEvt` | post to the asker | Pages and eviction answers |
| `BOOT_APP_RUN_SIG`, `BOOT_OS_SWITCH_SIG` | `BootRunEvt`, none | post to AO_Boot | After a verified write, `app_run`, or an adoption |
| `CONSOLE_RAW_REQ_SIG`, `CONSOLE_RAW_END_SIG` | `ConsoleRawReqEvt`, none | post to AO_Log | Arm raw mode; abandon it on a stall |

### Timers and limits

| Name | Value | Bounds |
| --- | --- | --- |
| `pacer` (`APPMGR_CHUNK_GAP_TICKS`) | 10 ms | The pause before a buffer goes back, and each 4 KB or 32 KB step of adoption and keeping |
| `idle`, `APPMGR_RECV_IDLE_TICKS` | 5 s (`KODE_RECV_IDLE_MS`) | The gap between two chunks of a console install |
| `idle`, `APPMGR_RECV_LONGSTOP_TICKS` | 30 s | The wait for the answer that arms raw mode |
| `idle`, `APPMGR_OPEN_TICKS` | 30 s | `opening`, and the STAT answer in `keeping` |
| `idle`, `APPMGR_CHUNK_TICKS` | 30 s | The gap between chunks in `flashing`. Expiry is `KFAIL_WRITE` |
| `idle`, `APPMGR_KEEP_WRITE_TICKS` | 60 s plus 1 s per 128 KB | One copy to the card |
| `pacer`, `APPMGR_KEEP_BEAT_TICKS` | 1 s | Re-publishes the percent while the card writes, so AO_Power keeps the LED pulsing |
| `answer`, `APPMGR_ANSWER_DRAIN_TICKS` | 50 ms | From "installed" to the reset |
| `KAPPS_MAX_ENTRIES` | 64 | Apps in the directory. A full listing is three pages |
| `KODE_APP_EVICT_MAX` | 8 | Victims in one eviction answer |
| `KODE_RECV_CHUNK` | 4096 | Console chunk, and the static adoption buffer |
| `KODE_FLASH_SRC_MAX` | 257 | A source with its terminator: a URL of up to 256 characters. A console line carries fewer |

The `idle` timer is shared by `opening`, `flashing`, `receiving` and `keeping`. These states exclude each other and each disarms it on exit. The timer `idle` and the state `idle` are two things.

### Console verbs, calls and settings

`app_list`, `app_run name="…"`, `app_erase name="…"`, `app_cat`, `install src=… dst=os|app|c5|pending`, with `name="<project>"` for `src="terminal" dst=app` only. The facade call is `kode_flash(src, dst, run)`: `src` is `"fs:<file>"`, `"sd:<file>"`, a bare name or an `http://` or `https://` URL, `dst` is `"os"` or `"app"`, and it posts and returns. AO_AppMgr owns the category override table of `kapp_cat`, an NVS blob. The format of the region and the directory is on `internals/apps-region`.

## Gotchas

> **WARNING**
>
> **A request that arrives during a write is dropped.** The log reads `busy writing the region (40%); request ignored`. Nothing is queued. Wait for `APP_FLASH_DONE_SIG` or `APP_FLASH_FAIL_SIG` before you ask for the next install or a run.

> **WARNING**
>
> **An uploaded sketch loops until you replace it.** Mailbox adoption at boot writes a sticky request. The bootloader keeps it across the resets a working app or its host produce, including the port opening, and retires it on power-on (BOT+LEFT), a hardware watchdog or brownout. A crashing sketch therefore restarts on its own until a new upload or BOT+LEFT (`internals/boot-model`).

> **WARNING**
>
> **An install from the card can fail with `KFAIL_KEEP` and write nothing.** An evicted app could not be copied to the card, and a card source means the card is the suspect. The install is cancelled before it evicts anything. Check the card with `test component=sd`.

> **NOTE**
>
> **A transient adoption failure repeats at every boot.** A directory commit error or an I/O error leaves the mailbox in place, so the next boot adopts it again. A verification failure consumes it. Read the log for `mailbox left intact` before you reflash the mailbox.

## Example code

Install an app from the card and start it. With `blinky.bin` on the card, the board writes it, resets and runs it.

```c
/*
 * install_from_card.c - 2026-10-28 - from Kode with love
 *
 * Asks AO_AppMgr to install /sdcard/blinky.bin into the apps region and run
 * it. kode_flash() posts the request and returns; AO_AppMgr reads the file
 * through AO_Storage, verifies it, commits the directory and resets into the
 * new app. On a Kode Dot the Opening card shows, then the app starts.
 */
#include "kode.h"

#define TAG "install"

/* Post the install, then wait for the reset. */
void app_main(void)
{
    kode_init();

    if (kode_flash("sd:blinky.bin", "app", true) != KODE_OK) {
        KLOG_E(TAG, "no installer in this firmware, or a bad argument");
        return;
    }
    KLOG_I(TAG, "install requested");
    for (;;) {
        kode_delay_ms(1000);
    }
}
```

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. **TBD**: a run on a Kode Dot.

## In the SDK

- [`components/kaos/appmgr`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/appmgr): `appmgr_ao.c`, the states, the copy and the adoption.
- [`components/kdrv/apps`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/apps): the directory format (`kapps_fmt.h`), the sinks and the decoder (`sdk/kdrv/apps`).
- [`kaos.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kaos/core/include/kaos.h): `appmgr_flash_request()`, `appmgr_list_page()` and the other request helpers.
