Skip to content

Commit e515d98

Browse files
authored
Merge pull request #96 from dasomji/agent/postbox-0.2.10
feat: harden Postbox lifecycle and Android notifications
2 parents 12b17c9 + 416a13d commit e515d98

82 files changed

Lines changed: 5169 additions & 239 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@ jobs:
1717
steps:
1818
- name: Check out repository
1919
uses: actions/checkout@v7
20+
with:
21+
fetch-depth: 0
2022
- name: Set up Node.js
2123
uses: actions/setup-node@v7
2224
with:
2325
node-version: 22
2426
cache: npm
2527
- name: Install dependencies
2628
run: npm ci
29+
- name: Check protocol version discipline
30+
run: node scripts/check-protocol-discipline.mjs "origin/${{ github.base_ref }}"
31+
- name: Check generated Android protocol contract
32+
run: npm run check:android-protocol-contract
2733
- name: Run tests
2834
run: npm test
2935
- name: Check web application
@@ -32,3 +38,37 @@ jobs:
3238
run: npm run build
3339
- name: Run packaged smoke test
3440
run: npm run smoke
41+
42+
android:
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 30
45+
steps:
46+
- name: Check out repository
47+
uses: actions/checkout@v7
48+
- name: Set up Node.js
49+
uses: actions/setup-node@v7
50+
with:
51+
node-version: 22
52+
cache: npm
53+
- name: Install dependencies
54+
run: npm ci
55+
- name: Check generated Android protocol contract
56+
run: npm run check:android-protocol-contract
57+
- name: Set up JDK 17
58+
uses: actions/setup-java@v5
59+
with:
60+
distribution: temurin
61+
java-version: "17"
62+
cache: gradle
63+
- name: Set up Android SDK
64+
uses: android-actions/setup-android@v3
65+
- name: Test, lint, and assemble Android
66+
run: ./apps/android/gradlew -p apps/android testDebugUnitTest lintDebug assembleDebug
67+
- name: Upload Android reports on failure
68+
if: failure()
69+
uses: actions/upload-artifact@v4
70+
with:
71+
name: android-reports
72+
path: |
73+
apps/android/app/build/reports
74+
apps/android/app/build/outputs/lint-results-debug.*

.github/workflows/publish.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ permissions:
1212
jobs:
1313
publish:
1414
runs-on: ubuntu-latest
15-
timeout-minutes: 20
15+
timeout-minutes: 30
1616
steps:
1717
- name: Check out repository
1818
uses: actions/checkout@v7
@@ -30,6 +30,22 @@ jobs:
3030
- name: Install dependencies
3131
run: npm ci
3232

33+
- name: Check generated Android protocol contract
34+
run: npm run check:android-protocol-contract
35+
36+
- name: Set up JDK 17
37+
uses: actions/setup-java@v5
38+
with:
39+
distribution: temurin
40+
java-version: "17"
41+
cache: gradle
42+
43+
- name: Set up Android SDK
44+
uses: android-actions/setup-android@v3
45+
46+
- name: Test, lint, and assemble Android
47+
run: ./apps/android/gradlew -p apps/android testDebugUnitTest lintDebug assembleDebug
48+
3349
- name: Typecheck
3450
run: npm run typecheck
3551

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ The product requirements document is in [`docs/prd/pi-postbox.md`](docs/prd/pi-p
88

99
## Current status
1010

11-
Issues #1-#11 provide the v1 implementation: runnable TypeScript workspace, `pi-postbox-server` CLI, Pi extension with `write_question`, WebSocket session registration, SSE browser state, SQLite persistence/history, structured Questions and options, semantic working/blocked/idle state, reconnect/idempotency/expiry, local terminal fallback commands, editable presentation metadata, and packaging/deployment docs plus a release smoke script. Version 0.2.5 keeps the footer and status surfaces from undercounting a locally tracked open Question when the durable owner snapshot is briefly stale, makes published package updates robust on npm 11, and prebuilds the shared protocol before clean-checkout test runs. Version 0.2.3 adds validated npm Trusted Publishing from pushes to the main branch through GitHub Actions OIDC. Version 0.2.2 adds complete npm license and source metadata, fixes the published CLI bin path, and excludes test source from the package tarball. Version 0.2.1 bounds and paginates every model-facing bulk read, uses compact stateless cursors, hides inactive historical owners by default, trims repeated list fields, and preserves checkout development ports across restarts. Version 0.2.0 replaces the separate model-facing create/update tools with one explicit-action `write_question` surface and returns reusable current Question handles from creation. Version 0.1.9 requires a concise ambiguity for new Questions, simplifies parent and expiry inputs, renames option `meaning` to `impact`, removes top-level handoff context and per-option context, removes reconstructed Question Chat, and renders single/multi choice with accessible ballot controls. Version 0.1.8 exposed single-Question create/idempotent receipt disposition and safely required a full Pi restart when `/reload` retained an incompatible shared protocol dependency. Version 0.1.7 reduced model-facing tool schemas, strictly described exact-owner filters, and made recovery reads compact by default with an explicit full view while preserving strict server-side action validation and internal provenance/expiry compatibility.
11+
Version 0.2.10 coordinates protocol 0.1.9 with Android 0.4.3 (build 7), keeps the current-owner footer count synchronized with Question lifecycle changes, makes configured development and production API ports strict, and adds a privacy-preserving Android notification-backed launcher badge for the full pending queue. It retains generated cross-language conformance fixtures, exact-match version gating, hard-block mismatch UX, durable Answer-driven agent auto-wake, and answer-ready widget cleanup after reads.
12+
13+
Issues #1-#11 provide the v1 implementation: runnable TypeScript workspace, `pi-postbox-server` CLI, Pi extension with `write_question`, WebSocket session registration, SSE browser state, SQLite persistence/history, structured Questions and options, semantic working/blocked/idle state, reconnect/idempotency/expiry, local terminal fallback commands, editable presentation metadata, and packaging/deployment docs plus a release smoke script. Version 0.2.6 updates Android 0.4.1 to include the server-required Question revision when submitting an Answer. Version 0.2.5 keeps the footer and status surfaces from undercounting a locally tracked open Question when the durable owner snapshot is briefly stale, makes published package updates robust on npm 11, and prebuilds the shared protocol before clean-checkout test runs. Version 0.2.3 adds validated npm Trusted Publishing from pushes to the main branch through GitHub Actions OIDC. Version 0.2.2 adds complete npm license and source metadata, fixes the published CLI bin path, and excludes test source from the package tarball. Version 0.2.1 bounds and paginates every model-facing bulk read, uses compact stateless cursors, hides inactive historical owners by default, trims repeated list fields, and preserves checkout development ports across restarts. Version 0.2.0 replaces the separate model-facing create/update tools with one explicit-action `write_question` surface and returns reusable current Question handles from creation. Version 0.1.9 requires a concise ambiguity for new Questions, simplifies parent and expiry inputs, renames option `meaning` to `impact`, removes top-level handoff context and per-option context, removes reconstructed Question Chat, and renders single/multi choice with accessible ballot controls. Version 0.1.8 exposed single-Question create/idempotent receipt disposition and safely required a full Pi restart when `/reload` retained an incompatible shared protocol dependency. Version 0.1.7 reduced model-facing tool schemas, strictly described exact-owner filters, and made recovery reads compact by default with an explicit full view while preserving strict server-side action validation and internal provenance/expiry compatibility.
1214

1315
## Quick start from this checkout
1416

@@ -24,7 +26,7 @@ Start the local server with defaults:
2426
node packages/server/dist/cli.js
2527
```
2628

27-
The server binds to `127.0.0.1`, treats port `32187` as the canonical default, stores data in `~/.pi-postbox/postbox.sqlite`, and prints the actual listening URL. If port `32187` is already in use, it automatically selects another local port and prints an explicit warning that the local/Tailnet bookmark URL is non-canonical; free `32187` or set `--port` / `PI_POSTBOX_PORT` to a stable available port if you need a bookmarkable URL.
29+
The production server binds to `127.0.0.1`, uses the fixed canonical port `32187`, stores data in `~/.pi-postbox/postbox.sqlite`, and prints the listening URL. If configured port `32187` is already in use, startup fails instead of silently changing the local and Tailnet URLs. Use `--port` / `PI_POSTBOX_PORT` only to configure a deliberate stable alternative.
2830

2931
## Workspace commands
3032

@@ -80,7 +82,7 @@ pi-postbox-server
8082

8183
## Agent tool contracts
8284

83-
`write_question` is the single model-facing write surface. Use `action: "create"` or `action: "create_batch"` to persist Questions, and `revise`, `cancel`, `supersede`, `reparent`, `transfer`, or `takeover` to change an existing Question. Creation returns after durable persistence rather than waiting for a human Answer. Continue any independent work and do not poll `get_answer`, `list_question_status`, or `list_questions`: Postbox notifies the owning Pi Session when an Answer is available. If that human decision becomes the only remaining blocker, call `wait_for_postbox` once to enter explicit idle/waiting mode; after it wakes, read the relevant Question with `get_answer`.
85+
`write_question` is the single model-facing write surface. Use `action: "create"` or `action: "create_batch"` to persist Questions, and `revise`, `cancel`, `supersede`, `reparent`, `transfer`, or `takeover` to change an existing Question. Creation returns after durable persistence rather than waiting for a human Answer. Continue any independent work and do not poll `get_answer`, `list_question_status`, or `list_questions`: by default, Postbox coalesces Answer notifications and starts a follow-up agent turn for an owning Pi Session that is no longer running. The privacy-preserving wake contains Question identifiers, never Answer content, and tells the agent to read with `get_answer`. If that human decision becomes the only remaining blocker during the current turn, `wait_for_postbox` remains available as an explicit idle/waiting mode.
8486

8587
Batch idempotency is per Question: put a stable `requestId` on each item. A top-level batch `requestId` is invalid because Postbox does not claim an atomic batch-level idempotency contract. Questions may refer to an earlier item with `parentLocalRef`; the server validates the ordered batch before persisting independent Question records:
8688

@@ -110,7 +112,7 @@ Agent query tools use small workflow-oriented results by default:
110112
- `list_questions` returns at most 100 records per page; `list_question_status` returns at most 50. Both accept only `scope` and a strict `{ "harness": "…", "ownerId": "…" }` owner filter on the model-facing surface, and return `nextCursor` when another page exists.
111113
- `list_postbox_owners()` derives the caller's feature scope, omits offline zero-count historical owners by default, and returns paged coarse presence and active/unread counts (100 maximum per page). Set `includeInactive: true` only for audit workflows.
112114
- `get_postbox_owner_status({ owners })` accepts at most 20 exact owner identities.
113-
- `get_answer({ questionId })` returns `{ "type": "pending", "status": "pending", "questionId": "…" }` while the Question remains unresolved; this is a normal bounded result, not an error.
115+
- `get_answer({ questionId })` returns `{ "type": "pending", "status": "pending", "questionId": "…" }` while the Question remains unresolved; this is a normal bounded result, not an error. After it successfully reads an Answer, the extension clears only that Answer's matching answer-ready widget.
114116

115117
Pagination cursors are opaque, compact, stateless, and bound to the original query. Copy them unchanged into the next call with the same filters; malformed or cross-query cursors are rejected.
116118

@@ -133,7 +135,7 @@ An answered request, a cancelled request, expiry, or Pi Session replacement thro
133135
Supported server flags and environment variables:
134136

135137
- `--host` or `PI_POSTBOX_HOST` (default `127.0.0.1`)
136-
- `--port` or `PI_POSTBOX_PORT` (preferred default `32187`; falls back to another local port if already in use)
138+
- `--port` or `PI_POSTBOX_PORT` (fixed canonical default `32187`; startup fails if the configured port is already in use)
137139
- `--ui-dist-dir` or `PI_POSTBOX_UI_DIST_DIR` (default packaged `dist/public` beside the server CLI)
138140
- `--database` or `PI_POSTBOX_DATABASE` (default `~/.pi-postbox/postbox.sqlite`)
139141
- `--profile` or `PI_POSTBOX_PROFILE` (`production` or `development:<checkout-id>`)
@@ -147,15 +149,18 @@ The extension resolves a server profile from the loaded package. Installed npm/g
147149

148150
```json
149151
{
150-
"serverUrl": "http://127.0.0.1:32187"
152+
"serverUrl": "http://127.0.0.1:32187",
153+
"autoWake": true
151154
}
152155
```
153156

157+
Answer auto-wake is enabled by default. Set `"autoWake": false` in the profile config, or set `PI_POSTBOX_AUTO_WAKE=off`, to keep widget-only notifications without starting an agent turn. `PI_POSTBOX_AUTO_WAKE=on` overrides a disabled config value.
158+
154159
Override config location with `PI_POSTBOX_CONFIG_PATH` or `PI_POSTBOX_CONFIG_DIR`. The extension creates a generated machine id on first startup and persists it in this config file. That generated machine id is stable across sessions; hostname and dashboard aliases provide human-readable names.
155160

156161
For local self-healing, each profile publishes only `<profile-state-dir>/active-local/server.json`. The extension validates that record against `/healthz` profile, instance, URL, protocol, and build identity. Health reports the package version separately from the protocol version, while the default build id fingerprints the loaded runtime bytes. It never orders or falls back across profiles. A global production loopback `serverUrl` is therefore invisible to a checkout development profile, while `PI_POSTBOX_URL` remains an intentional escape hatch.
157162

158-
`npm run dev` derives the checkout identity, selects independent backend/UI ports, persists them in the checkout profile's `dev-ports.json` for safe reuse across restarts, uses its own database and metadata, and never stops production. It relies on the server's content-specific build fingerprint instead of assigning one static build id to the checkout. It exposes the development API through Tailscale Serve when available and non-conflicting, using the API's separate port so the production mapping remains untouched. Set `PI_POSTBOX_TAILSCALE=off` to disable this exposure. Separate clones and worktrees can run concurrently. The dashboard title and persistent accessible `Development server` badge come from authoritative `/healthz` profile state.
163+
`npm run dev` derives the checkout identity, binds its API to the canonical development port `45795` (or an explicit `PI_POSTBOX_PORT`), selects and persists a separate Vite/HMR port in the checkout profile's `dev-ports.json`, uses its own database and metadata, and never stops production. If the API port is occupied, startup fails instead of changing the API URL; the Vite port may be safely reselected and persisted. The launcher relies on the server's content-specific build fingerprint instead of assigning one static build id to the checkout. It exposes the development API through Tailscale Serve when available and non-conflicting, using the API's separate port so the production mapping remains untouched. Set `PI_POSTBOX_TAILSCALE=off` to disable this exposure. The canonical API port is shared intentionally, so separate clones/worktrees need explicit distinct `PI_POSTBOX_PORT` values if run concurrently. The dashboard title and persistent accessible `Development server` badge come from authoritative `/healthz` profile state.
159164

160165
Package-local autostart is enabled by default for `write_question` and the user-only `/postbox` dashboard command. Set `PI_POSTBOX_AUTOSTART=off` to disable spawning a bundled server. Set `PI_POSTBOX_AUTOSTART_TIMEOUT_MS` to change the recovery wait; the default is 10 seconds (`10000` ms).
161166

apps/android/README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,26 +51,30 @@ https://postbox.your-tailnet.ts.net/
5151

5252
The app verifies the server with `GET /healthz` before saving the URL. Keep the existing Tailnet-private trust model: do not expose the Postbox server publicly for this prototype.
5353

54+
Android 0.4.3 (build 7) supports Postbox protocol 0.1.9 exactly. The app displays this identity before and after connection, rechecks saved endpoints, and blocks state, Answer, cancel, refresh, Question Chat, and push registration when the active endpoint reports missing or different protocol evidence. Regenerate the shared contract with `npm run generate:android-protocol-contract`; CI uses `npm run check:android-protocol-contract` plus Android unit/lint/assembly gates to reject drift.
55+
5456
## Emulator localhost fallback
5557

5658
Android emulators cannot reach the host machine at `localhost`. If the Postbox server is running on the development machine and you are testing in an emulator, use `10.0.2.2` as the host, for example:
5759

5860
```text
59-
http://10.0.2.2:32187/
61+
http://10.0.2.2:45795/
6062
```
6163

6264
Prefer the Tailnet HTTPS URL for real-device evidence; use the emulator fallback only for local development.
6365

6466
## Notifications and FCM push
6567

66-
The native app does not reuse browser Web Push subscriptions. Two notification paths share the same notifier and per-request notification ids, so a question never shows up twice:
68+
The native app does not reuse browser Web Push subscriptions. Two notification paths share the same notifier and per-request notification ids, so the same question does not produce duplicate per-question notifications:
6769

6870
1. **In-app observation**: while the connected question screen is active, fetched/SSE state snapshots are observed and newly seen pending request ids post one Android local notification.
69-
2. **FCM push (app closed or backgrounded)**: when Firebase is configured, the app registers its FCM token with the verified Postbox server, and the server pushes new pending questions as data-only FCM messages that `PostboxFirebaseMessagingService` renders through the same notifier.
71+
2. **FCM push (app closed or backgrounded)**: when Firebase is configured, the app registers its FCM token with the verified Postbox server, and the server pushes new pending questions as data-only FCM messages that `PostboxFirebaseMessagingService` renders through the same notifier. Its best-effort state prefetch also reconciles the full pending queue.
72+
73+
In addition to per-question notifications, every authoritative pending snapshot maintains one quiet, ongoing summary notification for the complete Android queue. The summary carries the pending count with Android's standard `setNumber` metadata and drives the launcher app-icon badge when the launcher supports notification badges. Launchers and OEMs control the presentation: some display the number and others display only a dot. Resolving the final pending question removes the summary and badge source.
7074

71-
The notification content is privacy-preserving (it does not include the question prompt); tapping it reopens the app and selects the relevant question if that request is still present in the latest observed state.
75+
All notification content is privacy-preserving and omits Question prompts. Tapping a per-question notification reopens the app and selects that Question if it is still present in the latest observed state; tapping the summary opens the app.
7276

73-
On Android 13+, the app requests `POST_NOTIFICATIONS` and gates posting on the runtime permission. If permission is denied, notifications are disabled but the question workflow remains usable: loading, viewing, answering, and cancelling questions are not blocked.
77+
On Android 13+, the app requests `POST_NOTIFICATIONS` and gates posting on the runtime permission. If permission is denied—or launcher/channel badges are disabled—the notification-backed launcher badge is unavailable, but the question workflow remains usable: loading, viewing, answering, and cancelling Questions are not blocked.
7478

7579
### Firebase setup
7680

0 commit comments

Comments
 (0)