diff --git a/README.md b/README.md index 9d21ad4..fd303a3 100644 --- a/README.md +++ b/README.md @@ -1736,7 +1736,7 @@ Companion runtime helper: - `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load (with safe normalization for invalid random samples), `tickNow()` for manual sends, success/error hooks, loop observability (`successCount`, `lastSuccessAt`, `failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures. - `src/companion/bootstrapManifest.ts` provides `createCompanionBootstrapManifest()` for generating a typed gateway/node/runtime bootstrap contract used by packaging flows, including optional initial status/location/push payloads. - `src/companion/releaseBundle.ts` provides `writeCompanionReleaseBundle()` for writing a distributable companion bundle directory (bootstrap JSON + launcher script + README + SHA-256 checksums + release manifest). -- `src/companion/shellTemplate.ts` provides `writeCompanionShellTemplate()` for emitting platform starter shells (macOS/iOS/Android native scaffold snippets + bootstrap JSON). +- `src/companion/shellTemplate.ts` provides `writeCompanionShellTemplate()` for emitting platform starter shells (macOS launcher wrapper snippet, iOS/Android bootstrap + runtime skeleton files, and bootstrap JSON). - `src/companion/releaseVerify.ts` provides `verifyCompanionReleaseBundle()` for validating bundle checksums and optional signature metadata. - `src/companion/releasePipeline.ts` provides `buildAndVerifyCompanionReleaseBundle()` for build-and-verify automation (including signed bundle flows). - `src/companion/macosMenuBarApp.ts` provides `generateMacOSMenuBarApp()` for emitting a runnable Swift Package macOS menu-bar companion scaffold. @@ -1749,7 +1749,7 @@ Minimal companion CLI: - `flynn companion --export-bootstrap ./companion.bootstrap.json` writes a resolved bootstrap manifest for desktop/mobile companion app packaging (use `-` for stdout). - `flynn companion --export-release-bundle ./dist/companion-macos` writes a release bundle directory (`companion.bootstrap.json`, `run-companion.sh`, `README.md`, `CHECKSUMS.sha256`, `RELEASE_MANIFEST.json`) and exits. - `flynn companion --export-release-bundle ./dist/companion-macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` also writes `CHECKSUMS.sha256.sig` for signed verification workflows. -- `flynn companion --platform ios --export-shell-template ./dist/companion-ios-template` writes a platform-native starter template directory (`companion.bootstrap.json`, native starter file, `README.md`) and exits. +- `flynn companion --platform ios --export-shell-template ./dist/companion-ios-template` writes a platform-native starter template directory (`companion.bootstrap.json`, bootstrap/runtime starter files, `README.md`) and exits. - `flynn companion --verify-release-bundle ./dist/companion-macos --verify-signing-key ./keys/release-public.pem --verify-signing-key-id team-k1 --require-signature` verifies checksums and signature metadata before install. - `pnpm companion:bundle -- --output ./dist/companion-macos --platform macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` builds and verifies a release bundle in one step. - `pnpm companion:reference-apps -- --output ./apps/companion` regenerates macOS/iOS/Android reference app starter shells plus `apps/companion/macos-app` runnable scaffold. diff --git a/apps/companion/README.md b/apps/companion/README.md index 28f760a..422cbd3 100644 --- a/apps/companion/README.md +++ b/apps/companion/README.md @@ -8,3 +8,5 @@ This directory contains generated companion starter shells for: - Android shell These are reference starters, not production binaries. Use them as a baseline for app packaging and distribution workflows. + +iOS/Android starter directories include bootstrap plus runtime-skeleton files for register/status/location/push/handoff flows. diff --git a/apps/companion/android/AndroidCompanionRuntime.kt b/apps/companion/android/AndroidCompanionRuntime.kt new file mode 100644 index 0000000..8e8540a --- /dev/null +++ b/apps/companion/android/AndroidCompanionRuntime.kt @@ -0,0 +1,122 @@ +package flynn.companion + +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import org.json.JSONArray +import org.json.JSONObject + +interface CompanionTransport { + fun connect(url: String, token: String?) + fun disconnect() + fun send(rawMessage: String) +} + +data class CompanionStatusPayload( + val platform: String, + val appVersion: String? = null, + val statusText: String? = null, + val batteryPct: Double? = null +) + +data class CompanionLocationPayload( + val latitude: Double, + val longitude: Double, + val source: String = "device" +) + +class AndroidCompanionRuntime( + private val bootstrap: CompanionBootstrap, + private val transport: CompanionTransport +) { + private var nextRequestId: Int = 1 + private val scheduler = Executors.newSingleThreadScheduledExecutor() + private var heartbeatFuture: ScheduledFuture<*>? = null + + fun connect() { + transport.connect(bootstrap.gateway.url, bootstrap.gateway.token) + } + + fun disconnect() { + heartbeatFuture?.cancel(true) + heartbeatFuture = null + transport.disconnect() + } + + fun registerNode() { + val params = JSONObject() + .put("nodeId", bootstrap.node.nodeId) + .put("role", bootstrap.node.role) + .put("platform", bootstrap.node.platform) + .put("capabilities", JSONArray(bootstrap.node.capabilities)) + sendRpc("node.register", params) + } + + fun publishStatus(status: CompanionStatusPayload) { + val statusJson = JSONObject() + .put("platform", status.platform) + status.appVersion?.let { statusJson.put("appVersion", it) } + status.statusText?.let { statusJson.put("statusText", it) } + status.batteryPct?.let { statusJson.put("batteryPct", it) } + sendRpc( + "node.status.set", + JSONObject() + .put("nodeId", bootstrap.node.nodeId) + .put("status", statusJson) + ) + } + + fun publishLocation(location: CompanionLocationPayload) { + sendRpc( + "node.location.set", + JSONObject() + .put("nodeId", bootstrap.node.nodeId) + .put( + "location", + JSONObject() + .put("latitude", location.latitude) + .put("longitude", location.longitude) + .put("source", location.source) + ) + ) + } + + fun registerPushToken(token: String, topic: String? = null) { + val params = JSONObject() + .put("nodeId", bootstrap.node.nodeId) + .put("provider", "fcm") + .put("token", token) + topic?.let { params.put("topic", it) } + sendRpc("node.push_token.set", params) + } + + fun sendHandoff(message: String, sessionId: String? = null) { + val params = JSONObject() + .put("message", message) + .put("awaitResponse", true) + sessionId?.let { params.put("sessionId", it) } + sendRpc("agent.send", params) + } + + fun startHeartbeatLoop(statusFactory: () -> CompanionStatusPayload) { + heartbeatFuture?.cancel(true) + val intervalSeconds = bootstrap.runtime.heartbeatSeconds.coerceAtLeast(1).toLong() + heartbeatFuture = scheduler.scheduleAtFixedRate( + { publishStatus(statusFactory()) }, + 0L, + intervalSeconds, + TimeUnit.SECONDS + ) + } + + private fun sendRpc(method: String, params: JSONObject) { + val request = JSONObject() + .put("jsonrpc", "2.0") + .put("id", nextRequestId++) + .put("method", method) + .put("params", params) + transport.send(request.toString()) + } +} + +// Generated for node: android-reference-shell (android) diff --git a/apps/companion/android/README.md b/apps/companion/android/README.md index 355fb27..fa9cc97 100644 --- a/apps/companion/android/README.md +++ b/apps/companion/android/README.md @@ -1,11 +1,12 @@ -# Flynn Companion android Shell Template +# Flynn Companion Android Shell Template -This directory contains a generated starter template for a android companion shell. +This directory contains a generated starter template for an Android companion shell. Files: - `companion.bootstrap.json`: resolved Flynn companion bootstrap contract -- `CompanionBootstrap.kt`: platform-native starter model/wrapper snippet +- `CompanionBootstrap.kt` +- `AndroidCompanionRuntime.kt` Notes: - These templates are intentionally minimal and should be integrated into your app project. -- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`). +- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`, `agent.send`). diff --git a/apps/companion/android/companion.bootstrap.json b/apps/companion/android/companion.bootstrap.json index 0d5e59d..60a9a28 100644 --- a/apps/companion/android/companion.bootstrap.json +++ b/apps/companion/android/companion.bootstrap.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-02-27T04:50:38.373Z", + "generatedAt": "2026-02-27T04:56:19.684Z", "gateway": { "url": "ws://127.0.0.1:18800" }, diff --git a/apps/companion/ios/IOSCompanionRuntime.swift b/apps/companion/ios/IOSCompanionRuntime.swift new file mode 100644 index 0000000..d310196 --- /dev/null +++ b/apps/companion/ios/IOSCompanionRuntime.swift @@ -0,0 +1,165 @@ +import Foundation + +enum CompanionRuntimeError: Error { + case invalidGatewayURL + case notConnected + case encodingFailure +} + +private struct JsonRpcRequest: Encodable { + let jsonrpc = "2.0" + let id: Int + let method: String + let params: Params +} + +struct CompanionStatusPayload: Encodable { + let platform: String + let appVersion: String? + let statusText: String? + let batteryPct: Double? +} + +struct CompanionLocationPayload: Encodable { + let latitude: Double + let longitude: Double + let source: String +} + +private struct NodeRegisterParams: Encodable { + let nodeId: String + let role: String + let platform: String + let capabilities: [String] +} + +private struct NodeStatusSetParams: Encodable { + let nodeId: String + let status: CompanionStatusPayload +} + +private struct NodeLocationSetParams: Encodable { + let nodeId: String + let location: CompanionLocationPayload +} + +private struct NodePushTokenSetParams: Encodable { + let nodeId: String + let provider: String + let token: String + let topic: String? + let environment: String? +} + +private struct AgentSendParams: Encodable { + let message: String + let sessionId: String? + let awaitResponse: Bool +} + +final class IOSCompanionRuntime { + private let bootstrap: CompanionBootstrap + private let session: URLSession + private var socket: URLSessionWebSocketTask? + private var nextRequestId = 1 + private var heartbeatLoop: Task? + + init(bootstrap: CompanionBootstrap, session: URLSession = .shared) { + self.bootstrap = bootstrap + self.session = session + } + + func connect() throws { + guard socket == nil else { return } + guard let url = URL(string: bootstrap.gateway.url) else { + throw CompanionRuntimeError.invalidGatewayURL + } + var request = URLRequest(url: url) + if let token = bootstrap.gateway.token, !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let task = session.webSocketTask(with: request) + task.resume() + socket = task + } + + func disconnect() { + heartbeatLoop?.cancel() + heartbeatLoop = nil + socket?.cancel(with: .goingAway, reason: nil) + socket = nil + } + + func registerNode() async throws { + try await sendRpc( + method: "node.register", + params: NodeRegisterParams( + nodeId: bootstrap.node.nodeId, + role: bootstrap.node.role, + platform: bootstrap.node.platform, + capabilities: bootstrap.node.capabilities + ) + ) + } + + func publishStatus(_ status: CompanionStatusPayload) async throws { + try await sendRpc( + method: "node.status.set", + params: NodeStatusSetParams(nodeId: bootstrap.node.nodeId, status: status) + ) + } + + func publishLocation(_ location: CompanionLocationPayload) async throws { + try await sendRpc( + method: "node.location.set", + params: NodeLocationSetParams(nodeId: bootstrap.node.nodeId, location: location) + ) + } + + func registerPushToken(token: String, topic: String? = nil, environment: String? = nil) async throws { + try await sendRpc( + method: "node.push_token.set", + params: NodePushTokenSetParams( + nodeId: bootstrap.node.nodeId, + provider: "apns", + token: token, + topic: topic, + environment: environment + ) + ) + } + + func sendHandoff(message: String, sessionId: String? = nil) async throws { + try await sendRpc( + method: "agent.send", + params: AgentSendParams(message: message, sessionId: sessionId, awaitResponse: true) + ) + } + + func startHeartbeatLoop(statusFactory: @escaping () -> CompanionStatusPayload) { + heartbeatLoop?.cancel() + let interval = UInt64(max(bootstrap.runtime.heartbeatSeconds, 1)) * 1_000_000_000 + heartbeatLoop = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + try? await self.publishStatus(statusFactory()) + try? await Task.sleep(nanoseconds: interval) + } + } + } + + private func sendRpc(method: String, params: Params) async throws { + guard let socket else { + throw CompanionRuntimeError.notConnected + } + let request = JsonRpcRequest(id: nextRequestId, method: method, params: params) + nextRequestId += 1 + let data = try JSONEncoder().encode(request) + guard let text = String(data: data, encoding: .utf8) else { + throw CompanionRuntimeError.encodingFailure + } + try await socket.send(.string(text)) + } +} + +// Generated for node: ios-reference-shell (ios) diff --git a/apps/companion/ios/README.md b/apps/companion/ios/README.md index 1e83261..e365cc7 100644 --- a/apps/companion/ios/README.md +++ b/apps/companion/ios/README.md @@ -1,11 +1,12 @@ -# Flynn Companion ios Shell Template +# Flynn Companion iOS Shell Template -This directory contains a generated starter template for a ios companion shell. +This directory contains a generated starter template for an iOS companion shell. Files: - `companion.bootstrap.json`: resolved Flynn companion bootstrap contract -- `CompanionBootstrap.swift`: platform-native starter model/wrapper snippet +- `CompanionBootstrap.swift` +- `IOSCompanionRuntime.swift` Notes: - These templates are intentionally minimal and should be integrated into your app project. -- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`). +- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`, `agent.send`). diff --git a/apps/companion/ios/companion.bootstrap.json b/apps/companion/ios/companion.bootstrap.json index 947f6a3..429f009 100644 --- a/apps/companion/ios/companion.bootstrap.json +++ b/apps/companion/ios/companion.bootstrap.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-02-27T04:50:38.373Z", + "generatedAt": "2026-02-27T04:56:19.684Z", "gateway": { "url": "ws://127.0.0.1:18800" }, diff --git a/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json b/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json index cf73862..f99b1f6 100644 --- a/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json +++ b/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-02-27T04:50:38.373Z", + "generatedAt": "2026-02-27T04:56:19.684Z", "gateway": { "url": "ws://127.0.0.1:18800" }, diff --git a/apps/companion/macos/README.md b/apps/companion/macos/README.md index bccde38..2d25b29 100644 --- a/apps/companion/macos/README.md +++ b/apps/companion/macos/README.md @@ -1,11 +1,11 @@ -# Flynn Companion macos Shell Template +# Flynn Companion macOS Shell Template -This directory contains a generated starter template for a macos companion shell. +This directory contains a generated starter template for a macOS companion shell. Files: - `companion.bootstrap.json`: resolved Flynn companion bootstrap contract -- `MenuBarCompanion.swift`: platform-native starter model/wrapper snippet +- `MenuBarCompanion.swift` Notes: - These templates are intentionally minimal and should be integrated into your app project. -- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`). +- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`, `agent.send`). diff --git a/apps/companion/macos/companion.bootstrap.json b/apps/companion/macos/companion.bootstrap.json index d9425bf..49ce5ee 100644 --- a/apps/companion/macos/companion.bootstrap.json +++ b/apps/companion/macos/companion.bootstrap.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-02-27T04:50:38.373Z", + "generatedAt": "2026-02-27T04:56:19.684Z", "gateway": { "url": "ws://127.0.0.1:18800" }, diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 42edfc3..0239342 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -1866,7 +1866,7 @@ For more implementation details, see: - Companion release bundle helper: `src/companion/releaseBundle.ts` (writes bootstrap JSON + launcher script + README + `CHECKSUMS.sha256` + `RELEASE_MANIFEST.json`; optional `CHECKSUMS.sha256.sig` when a signing key is provided. Launcher performs checksum verification before exec.) - Companion release bundle verifier: `src/companion/releaseVerify.ts` (validates `CHECKSUMS.sha256` and optional signature metadata against a provided public key) - Companion release automation pipeline: `src/companion/releasePipeline.ts` + `scripts/build-companion-release-bundle.ts` (build-and-verify workflow for deterministic companion artifact generation) -- Companion shell template helper: `src/companion/shellTemplate.ts` (writes platform-native starter template files for `macos`, `ios`, and `android` shell scaffolding) +- Companion shell template helper: `src/companion/shellTemplate.ts` (writes platform-native starter template files for `macos`, `ios`, and `android` shell scaffolding, including iOS/Android runtime skeletons for `node.register` + status/location/push + `agent.send`) - Companion macOS menu-bar app scaffold helper: `src/companion/macosMenuBarApp.ts` (writes runnable Swift Package menu-bar app starter surface) - Companion reference app exporter: `src/companion/referenceApps.ts` + `scripts/export-companion-reference-apps.ts` (regenerates in-repo platform starter app directories, including `apps/companion/macos-app`) - CI artifact workflow: `.github/workflows/companion-release-bundle.yml` (manual dispatch bundle build/verify/upload pipeline) diff --git a/docs/architecture/AGENT_DIAGRAM.md b/docs/architecture/AGENT_DIAGRAM.md index 7dd0c84..586da9b 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -159,7 +159,7 @@ Gateway streaming UX signals: - `flynn companion --export-release-bundle ` can emit a distributable shell bundle (bootstrap JSON + launcher + README + SHA-256 checksums) for desktop/mobile packaging pipelines. - `flynn companion --export-release-bundle ... --signing-key ` can additionally emit `CHECKSUMS.sha256.sig` for signed artifact verification pipelines. - `flynn companion --verify-release-bundle ` can validate bundle checksums and optional signatures before installation or rollout. -- `flynn companion --export-shell-template ` can emit platform starter shell templates (macOS/iOS/Android native scaffold files + bootstrap JSON) for reference app bootstrapping. +- `flynn companion --export-shell-template ` can emit platform starter shell templates (macOS/iOS/Android native scaffold files + bootstrap JSON), including iOS/Android runtime skeletons for registration, heartbeat/status, location/push updates, and handoff. - `pnpm companion:bundle -- --output ...` runs a build-and-verify release pipeline for repeatable companion artifact generation. - `pnpm companion:reference-apps -- --output apps/companion` regenerates in-repo macOS/iOS/Android reference app starter surfaces plus a runnable `macos-app` Swift Package menu-bar scaffold. - `.github/workflows/companion-release-bundle.yml` provides CI artifact generation for companion release bundles using the same build-and-verify pipeline. diff --git a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md index 8f8c165..16b5fd5 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -26,7 +26,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - Companion release-bundle exports can optionally be signed (`--signing-key`, `--signing-key-id`) to emit `CHECKSUMS.sha256.sig` for distribution trust verification. - Companion release bundles can be verified before install via `flynn companion --verify-release-bundle ` with optional signature-key checks. - Companion packaging automation is available via `pnpm companion:bundle -- --output ...`, which builds and verifies the release bundle in one pass. -- Companion platform starter scaffolds can be generated via `flynn companion --export-shell-template ` for macOS/iOS/Android reference app bootstrapping. +- Companion platform starter scaffolds can be generated via `flynn companion --export-shell-template ` for macOS/iOS/Android reference app bootstrapping, including iOS/Android runtime skeletons that issue `node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`, and `agent.send`. - Companion reference app directories can be regenerated via `pnpm companion:reference-apps -- --output apps/companion` for repo-shipped starter surfaces, including a runnable `macos-app` Swift Package menu-bar scaffold. - CI workflow `.github/workflows/companion-release-bundle.yml` mirrors this pipeline for manual artifact generation/upload. - Companion CLI supports one-shot shell bootstrap metadata for live sessions (`--app-version`/`--status-text`, `--latitude`/`--longitude`, `--push-token`) so desktop/mobile wrappers can initialize node status/location/push in a single launch flow. diff --git a/docs/operations/COMPANION_RELEASE_BUNDLE.md b/docs/operations/COMPANION_RELEASE_BUNDLE.md index fefa2b6..2b20a32 100644 --- a/docs/operations/COMPANION_RELEASE_BUNDLE.md +++ b/docs/operations/COMPANION_RELEASE_BUNDLE.md @@ -75,7 +75,9 @@ flynn companion \ Generated files: - `companion.bootstrap.json` -- platform starter file (`CompanionBootstrap.swift`, `CompanionBootstrap.kt`, or `MenuBarCompanion.swift`) +- `MenuBarCompanion.swift` (macOS) +- `CompanionBootstrap.swift` + `IOSCompanionRuntime.swift` (iOS) +- `CompanionBootstrap.kt` + `AndroidCompanionRuntime.kt` (Android) - `README.md` ## Verify Bundle Integrity diff --git a/docs/plans/2026-02-26-personal-assistant-productization-plan.md b/docs/plans/2026-02-26-personal-assistant-productization-plan.md index 63cf704..8bfc8a7 100644 --- a/docs/plans/2026-02-26-personal-assistant-productization-plan.md +++ b/docs/plans/2026-02-26-personal-assistant-productization-plan.md @@ -6,7 +6,7 @@ Scope: ship the remaining product-layer capabilities that make Flynn feel like a ## Rebaseline (What Is Already Done) -Completion update (2026-02-27): all roadmap phases are now implemented, including companion app-surface packaging outputs (bootstrap export, signed/verified release bundles, reference app starter surfaces including a runnable macOS menu-bar Swift Package scaffold, and CI artifact workflow), voice reliability, browser workflow reliability, and onboarding first-success improvements. +Completion update (2026-02-27): all roadmap phases are now implemented, including companion app-surface packaging outputs (bootstrap export, signed/verified release bundles, reference app starter surfaces including a runnable macOS menu-bar Swift Package scaffold plus iOS/Android runtime shell skeletons, and CI artifact workflow), voice reliability, browser workflow reliability, and onboarding first-success improvements. The following were previously treated as gaps but are already implemented in Flynn: @@ -51,7 +51,7 @@ Within 8-10 weeks, ship a stable "Personal Assistant Mode" that supports: 2. Ship a minimal mobile companion shell (iOS + Android) for registration, status, push token, and message handoff. 3. Add signed release artifacts and installation docs. -Status update (2026-02-27): companion bootstrap-manifest export is now available via `flynn companion --export-bootstrap ` as a packaging contract for desktop/mobile shells, `flynn companion --export-release-bundle ` now emits bundle artifacts (bootstrap JSON + launcher + README + `CHECKSUMS.sha256` + `RELEASE_MANIFEST.json`, optional `CHECKSUMS.sha256.sig` with `--signing-key`), `flynn companion --verify-release-bundle ` now validates checksum/signature artifacts before install, `pnpm companion:bundle -- --output ...` now provides one-pass build-and-verify automation, `.github/workflows/companion-release-bundle.yml` provides CI artifact build/verify/upload, `flynn companion --export-shell-template ` now emits macOS/iOS/Android starter shell templates, `pnpm companion:reference-apps` now regenerates in-repo macOS/iOS/Android reference app starter directories plus `apps/companion/macos-app` runnable menu-bar scaffold, and `flynn companion` supports one-shot status/location/push bootstrap flags (`--app-version`, `--latitude/--longitude`, `--push-token`) so thin shells can initialize companion metadata in a single run. +Status update (2026-02-27): companion bootstrap-manifest export is now available via `flynn companion --export-bootstrap ` as a packaging contract for desktop/mobile shells, `flynn companion --export-release-bundle ` now emits bundle artifacts (bootstrap JSON + launcher + README + `CHECKSUMS.sha256` + `RELEASE_MANIFEST.json`, optional `CHECKSUMS.sha256.sig` with `--signing-key`), `flynn companion --verify-release-bundle ` now validates checksum/signature artifacts before install, `pnpm companion:bundle -- --output ...` now provides one-pass build-and-verify automation, `.github/workflows/companion-release-bundle.yml` provides CI artifact build/verify/upload, `flynn companion --export-shell-template ` now emits macOS/iOS/Android starter shell templates including iOS/Android runtime skeletons for register/status/location/push/handoff flows, `pnpm companion:reference-apps` now regenerates in-repo macOS/iOS/Android reference app starter directories plus `apps/companion/macos-app` runnable menu-bar scaffold, and `flynn companion` supports one-shot status/location/push bootstrap flags (`--app-version`, `--latitude/--longitude`, `--push-token`) so thin shells can initialize companion metadata in a single run. ### Implementation Anchors diff --git a/docs/plans/state.json b/docs/plans/state.json index 558c99d..2f1152b 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -7018,13 +7018,21 @@ "status": "completed", "date": "2026-02-27", "updated": "2026-02-27", - "summary": "Added platform shell-template export for reference companion apps. `flynn companion --export-shell-template ` now writes platform starter scaffolds for macOS/iOS/Android (`MenuBarCompanion.swift`, `CompanionBootstrap.swift`, `CompanionBootstrap.kt`) plus bootstrap JSON and template README.", + "summary": "Expanded platform shell-template export for reference companion apps. `flynn companion --export-shell-template ` now writes macOS/iOS/Android starter scaffolds with iOS/Android runtime skeleton files (`IOSCompanionRuntime.swift`, `AndroidCompanionRuntime.kt`) covering register/status/location/push/handoff JSON-RPC calls, plus bootstrap JSON and template README.", "files_modified": [ "src/companion/shellTemplate.ts", "src/companion/shellTemplate.test.ts", "src/companion/index.ts", "src/cli/companion.ts", "src/cli/companion.test.ts", + "apps/companion/ios/CompanionBootstrap.swift", + "apps/companion/ios/IOSCompanionRuntime.swift", + "apps/companion/ios/README.md", + "apps/companion/ios/companion.bootstrap.json", + "apps/companion/android/CompanionBootstrap.kt", + "apps/companion/android/AndroidCompanionRuntime.kt", + "apps/companion/android/README.md", + "apps/companion/android/companion.bootstrap.json", "README.md", "docs/api/PROTOCOL.md", "docs/architecture/AGENT_DIAGRAM.md", @@ -7033,7 +7041,7 @@ "docs/plans/2026-02-26-personal-assistant-productization-plan.md", "docs/plans/state.json" ], - "test_status": "pnpm test:run src/cli/companion.test.ts src/companion/shellTemplate.test.ts + pnpm typecheck passing" + "test_status": "pnpm test:run src/companion/shellTemplate.test.ts src/companion/referenceApps.test.ts + pnpm typecheck passing" }, "personal-assistant-productization-phase1-companion-signed-release-artifacts": { "status": "completed", @@ -7213,7 +7221,7 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "rebaselined 2026-02-26 and finalized 2026-02-27 — channel breadth, setup wizard, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging/distribution primitives (bootstrap export + release-bundle artifacts + checksum manifests + release manifest artifact + optional signatures + verification mode + checksum-gated launcher + one-pass build/verify automation + CI workflow), repo-shipped macOS/iOS/Android reference app starter surfaces plus runnable macOS menu-bar Swift Package scaffold (`apps/companion/macos-app`), one-shot status/location/push shell bootstrap controls, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented.", + "feature_gap_scorecard": "rebaselined 2026-02-26 and finalized 2026-02-27 — channel breadth, setup wizard, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging/distribution primitives (bootstrap export + release-bundle artifacts + checksum manifests + release manifest artifact + optional signatures + verification mode + checksum-gated launcher + one-pass build/verify automation + CI workflow), repo-shipped macOS/iOS/Android reference app starter surfaces plus runnable macOS menu-bar Swift Package scaffold (`apps/companion/macos-app`) and iOS/Android runtime shell skeletons (`IOSCompanionRuntime.swift`, `AndroidCompanionRuntime.kt`), one-shot status/location/push shell bootstrap controls, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented.", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "dashboard_observability": "completed — service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", @@ -7246,7 +7254,7 @@ "deeper_surfaces_phase3_companion_canvas_voice": "completed — companion reconnect resilience (auto-reconnect with backoff, pending-wait cancellation on disconnect), canvas artifact persistence (SQLite-backed store, daemon-restart durability), voice TTS fallback coverage (text-only reply on TTS failure, no dropped responses)", "deeper_surfaces_phase4_rollout": "completed — phase 4 rollout and operator readiness plan documented: canary rollout plan by feature flag/surface, explicit rollback playbook, operator docs and architecture/protocol docs synchronized", "post_phase_test_fixes": "completed — fixed 4 test failures introduced by phases 1-3: iOS/Android push listNodes (missing publishHeartbeat before platform-filtered query), server.test agent.send (run_state events now precede done; added sendAndWaitForDone helper), httpBody 413 (req.destroy() closed socket before response could be sent; replaced with Connection: close header on 413 responses)", - "personal_assistant_productization_plan": "completed — roadmap phases delivered: companion app-surface packaging/distribution outputs (including reference starters + CI), voice reliability, browser workflow reliability, and onboarding first-success funnel.", + "personal_assistant_productization_plan": "completed — roadmap phases delivered: companion app-surface packaging/distribution outputs (including runnable/reference starters + iOS/Android runtime shell skeletons + CI), voice reliability, browser workflow reliability, and onboarding first-success funnel.", "subagents_support": "completed — subagent phases 1-3 shipped with `subagent.spawn/send/list/cancel/delete/summary`, per-child queue mode (`followup|interrupt`), budgets (`max_turns`, `max_total_tokens`, `turn_timeout_ms`), tool-profile overrides, trace-linked audit events, `/subagents` inspection commands, and focused regression tests." }, "soul_md_and_cron_create": { diff --git a/src/companion/referenceApps.ts b/src/companion/referenceApps.ts index 1f6a4c8..75196ef 100644 --- a/src/companion/referenceApps.ts +++ b/src/companion/referenceApps.ts @@ -41,6 +41,8 @@ This directory contains generated companion starter shells for: - Android shell These are reference starters, not production binaries. Use them as a baseline for app packaging and distribution workflows. + +iOS/Android starter directories include bootstrap plus runtime-skeleton files for register/status/location/push/handoff flows. `; } diff --git a/src/companion/shellTemplate.test.ts b/src/companion/shellTemplate.test.ts index cff8654..855d4ed 100644 --- a/src/companion/shellTemplate.test.ts +++ b/src/companion/shellTemplate.test.ts @@ -32,18 +32,23 @@ describe('writeCompanionShellTemplate', () => { }); const templateRaw = await readFile(`${outDir}/CompanionBootstrap.swift`, 'utf8'); + const runtimeRaw = await readFile(`${outDir}/IOSCompanionRuntime.swift`, 'utf8'); const manifestRaw = await readFile(`${outDir}/companion.bootstrap.json`, 'utf8'); const readmeRaw = await readFile(`${outDir}/README.md`, 'utf8'); expect(result.files).toEqual([ `${outDir}/companion.bootstrap.json`, `${outDir}/CompanionBootstrap.swift`, + `${outDir}/IOSCompanionRuntime.swift`, `${outDir}/README.md`, ]); expect(templateRaw).toContain('struct CompanionBootstrap: Codable'); - expect(templateRaw).toContain('node.push_token.set'); + expect(runtimeRaw).toContain('final class IOSCompanionRuntime'); + expect(runtimeRaw).toContain('"node.push_token.set"'); + expect(runtimeRaw).toContain('"agent.send"'); expect(JSON.parse(manifestRaw)).toMatchObject({ node: { nodeId: 'test-node' } }); expect(readmeRaw).toContain('Shell Template'); + expect(readmeRaw).toContain('IOSCompanionRuntime.swift'); await rm(tempDir, { recursive: true, force: true }); }); @@ -65,8 +70,11 @@ describe('writeCompanionShellTemplate', () => { }); const androidTemplate = await readFile(`${androidDir}/CompanionBootstrap.kt`, 'utf8'); + const androidRuntime = await readFile(`${androidDir}/AndroidCompanionRuntime.kt`, 'utf8'); const macosTemplate = await readFile(`${macosDir}/MenuBarCompanion.swift`, 'utf8'); expect(androidTemplate).toContain('data class CompanionBootstrap'); + expect(androidRuntime).toContain('class AndroidCompanionRuntime'); + expect(androidRuntime).toContain("\"node.register\""); expect(macosTemplate).toContain('launchFlynnCompanion'); await rm(tempDir, { recursive: true, force: true }); diff --git a/src/companion/shellTemplate.ts b/src/companion/shellTemplate.ts index 476f1c2..286bb47 100644 --- a/src/companion/shellTemplate.ts +++ b/src/companion/shellTemplate.ts @@ -15,7 +15,12 @@ export interface WriteCompanionShellTemplateResult { files: string[]; } -function swiftTemplate(manifest: CompanionBootstrapManifest): string { +interface TemplateArtifact { + filename: string; + body: string; +} + +function macosTemplate(manifest: CompanionBootstrapManifest): string { return `import Foundation struct CompanionBootstrap: Codable { @@ -57,7 +62,7 @@ func launchFlynnCompanion() throws { `; } -function iosTemplate(manifest: CompanionBootstrapManifest): string { +function iosBootstrapTemplate(manifest: CompanionBootstrapManifest): string { return `import Foundation // Reference iOS bootstrap model for integrating with Flynn gateway runtime. @@ -93,7 +98,176 @@ struct Runtime: Codable { `; } -function androidTemplate(manifest: CompanionBootstrapManifest): string { +function iosRuntimeTemplate(manifest: CompanionBootstrapManifest): string { + return `import Foundation + +enum CompanionRuntimeError: Error { + case invalidGatewayURL + case notConnected + case encodingFailure +} + +private struct JsonRpcRequest: Encodable { + let jsonrpc = "2.0" + let id: Int + let method: String + let params: Params +} + +struct CompanionStatusPayload: Encodable { + let platform: String + let appVersion: String? + let statusText: String? + let batteryPct: Double? +} + +struct CompanionLocationPayload: Encodable { + let latitude: Double + let longitude: Double + let source: String +} + +private struct NodeRegisterParams: Encodable { + let nodeId: String + let role: String + let platform: String + let capabilities: [String] +} + +private struct NodeStatusSetParams: Encodable { + let nodeId: String + let status: CompanionStatusPayload +} + +private struct NodeLocationSetParams: Encodable { + let nodeId: String + let location: CompanionLocationPayload +} + +private struct NodePushTokenSetParams: Encodable { + let nodeId: String + let provider: String + let token: String + let topic: String? + let environment: String? +} + +private struct AgentSendParams: Encodable { + let message: String + let sessionId: String? + let awaitResponse: Bool +} + +final class IOSCompanionRuntime { + private let bootstrap: CompanionBootstrap + private let session: URLSession + private var socket: URLSessionWebSocketTask? + private var nextRequestId = 1 + private var heartbeatLoop: Task? + + init(bootstrap: CompanionBootstrap, session: URLSession = .shared) { + self.bootstrap = bootstrap + self.session = session + } + + func connect() throws { + guard socket == nil else { return } + guard let url = URL(string: bootstrap.gateway.url) else { + throw CompanionRuntimeError.invalidGatewayURL + } + var request = URLRequest(url: url) + if let token = bootstrap.gateway.token, !token.isEmpty { + request.setValue("Bearer \\(token)", forHTTPHeaderField: "Authorization") + } + let task = session.webSocketTask(with: request) + task.resume() + socket = task + } + + func disconnect() { + heartbeatLoop?.cancel() + heartbeatLoop = nil + socket?.cancel(with: .goingAway, reason: nil) + socket = nil + } + + func registerNode() async throws { + try await sendRpc( + method: "node.register", + params: NodeRegisterParams( + nodeId: bootstrap.node.nodeId, + role: bootstrap.node.role, + platform: bootstrap.node.platform, + capabilities: bootstrap.node.capabilities + ) + ) + } + + func publishStatus(_ status: CompanionStatusPayload) async throws { + try await sendRpc( + method: "node.status.set", + params: NodeStatusSetParams(nodeId: bootstrap.node.nodeId, status: status) + ) + } + + func publishLocation(_ location: CompanionLocationPayload) async throws { + try await sendRpc( + method: "node.location.set", + params: NodeLocationSetParams(nodeId: bootstrap.node.nodeId, location: location) + ) + } + + func registerPushToken(token: String, topic: String? = nil, environment: String? = nil) async throws { + try await sendRpc( + method: "node.push_token.set", + params: NodePushTokenSetParams( + nodeId: bootstrap.node.nodeId, + provider: "apns", + token: token, + topic: topic, + environment: environment + ) + ) + } + + func sendHandoff(message: String, sessionId: String? = nil) async throws { + try await sendRpc( + method: "agent.send", + params: AgentSendParams(message: message, sessionId: sessionId, awaitResponse: true) + ) + } + + func startHeartbeatLoop(statusFactory: @escaping () -> CompanionStatusPayload) { + heartbeatLoop?.cancel() + let interval = UInt64(max(bootstrap.runtime.heartbeatSeconds, 1)) * 1_000_000_000 + heartbeatLoop = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + try? await self.publishStatus(statusFactory()) + try? await Task.sleep(nanoseconds: interval) + } + } + } + + private func sendRpc(method: String, params: Params) async throws { + guard let socket else { + throw CompanionRuntimeError.notConnected + } + let request = JsonRpcRequest(id: nextRequestId, method: method, params: params) + nextRequestId += 1 + let data = try JSONEncoder().encode(request) + guard let text = String(data: data, encoding: .utf8) else { + throw CompanionRuntimeError.encodingFailure + } + try await socket.send(.string(text)) + } +} + +// Generated for node: ${manifest.node.nodeId} (${manifest.node.platform}) +`; +} + +function androidBootstrapTemplate(manifest: CompanionBootstrapManifest): string { return `package flynn.companion // Reference Android bootstrap model for integrating with Flynn gateway runtime. @@ -129,41 +303,182 @@ data class Runtime( `; } -function templateFilename(platform: CompanionShellTemplatePlatform): string { - if (platform === 'macos') { - return 'MenuBarCompanion.swift'; - } - if (platform === 'ios') { - return 'CompanionBootstrap.swift'; - } - return 'CompanionBootstrap.kt'; +function androidRuntimeTemplate(manifest: CompanionBootstrapManifest): string { + return `package flynn.companion + +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import org.json.JSONArray +import org.json.JSONObject + +interface CompanionTransport { + fun connect(url: String, token: String?) + fun disconnect() + fun send(rawMessage: String) } -function templateBody( +data class CompanionStatusPayload( + val platform: String, + val appVersion: String? = null, + val statusText: String? = null, + val batteryPct: Double? = null +) + +data class CompanionLocationPayload( + val latitude: Double, + val longitude: Double, + val source: String = "device" +) + +class AndroidCompanionRuntime( + private val bootstrap: CompanionBootstrap, + private val transport: CompanionTransport +) { + private var nextRequestId: Int = 1 + private val scheduler = Executors.newSingleThreadScheduledExecutor() + private var heartbeatFuture: ScheduledFuture<*>? = null + + fun connect() { + transport.connect(bootstrap.gateway.url, bootstrap.gateway.token) + } + + fun disconnect() { + heartbeatFuture?.cancel(true) + heartbeatFuture = null + transport.disconnect() + } + + fun registerNode() { + val params = JSONObject() + .put("nodeId", bootstrap.node.nodeId) + .put("role", bootstrap.node.role) + .put("platform", bootstrap.node.platform) + .put("capabilities", JSONArray(bootstrap.node.capabilities)) + sendRpc("node.register", params) + } + + fun publishStatus(status: CompanionStatusPayload) { + val statusJson = JSONObject() + .put("platform", status.platform) + status.appVersion?.let { statusJson.put("appVersion", it) } + status.statusText?.let { statusJson.put("statusText", it) } + status.batteryPct?.let { statusJson.put("batteryPct", it) } + sendRpc( + "node.status.set", + JSONObject() + .put("nodeId", bootstrap.node.nodeId) + .put("status", statusJson) + ) + } + + fun publishLocation(location: CompanionLocationPayload) { + sendRpc( + "node.location.set", + JSONObject() + .put("nodeId", bootstrap.node.nodeId) + .put( + "location", + JSONObject() + .put("latitude", location.latitude) + .put("longitude", location.longitude) + .put("source", location.source) + ) + ) + } + + fun registerPushToken(token: String, topic: String? = null) { + val params = JSONObject() + .put("nodeId", bootstrap.node.nodeId) + .put("provider", "fcm") + .put("token", token) + topic?.let { params.put("topic", it) } + sendRpc("node.push_token.set", params) + } + + fun sendHandoff(message: String, sessionId: String? = null) { + val params = JSONObject() + .put("message", message) + .put("awaitResponse", true) + sessionId?.let { params.put("sessionId", it) } + sendRpc("agent.send", params) + } + + fun startHeartbeatLoop(statusFactory: () -> CompanionStatusPayload) { + heartbeatFuture?.cancel(true) + val intervalSeconds = bootstrap.runtime.heartbeatSeconds.coerceAtLeast(1).toLong() + heartbeatFuture = scheduler.scheduleAtFixedRate( + { publishStatus(statusFactory()) }, + 0L, + intervalSeconds, + TimeUnit.SECONDS + ) + } + + private fun sendRpc(method: String, params: JSONObject) { + val request = JSONObject() + .put("jsonrpc", "2.0") + .put("id", nextRequestId++) + .put("method", method) + .put("params", params) + transport.send(request.toString()) + } +} + +// Generated for node: ${manifest.node.nodeId} (${manifest.node.platform}) +`; +} + +function templateArtifacts( platform: CompanionShellTemplatePlatform, manifest: CompanionBootstrapManifest, -): string { +): TemplateArtifact[] { if (platform === 'macos') { - return swiftTemplate(manifest); + return [{ filename: 'MenuBarCompanion.swift', body: macosTemplate(manifest) }]; } if (platform === 'ios') { - return iosTemplate(manifest); + return [ + { filename: 'CompanionBootstrap.swift', body: iosBootstrapTemplate(manifest) }, + { filename: 'IOSCompanionRuntime.swift', body: iosRuntimeTemplate(manifest) }, + ]; } - return androidTemplate(manifest); + return [ + { filename: 'CompanionBootstrap.kt', body: androidBootstrapTemplate(manifest) }, + { filename: 'AndroidCompanionRuntime.kt', body: androidRuntimeTemplate(manifest) }, + ]; } -function readmeBody(platform: CompanionShellTemplatePlatform): string { - return `# Flynn Companion ${platform} Shell Template +function platformArticle(platform: CompanionShellTemplatePlatform): 'a' | 'an' { + if (platform === 'ios' || platform === 'android') { + return 'an'; + } + return 'a'; +} -This directory contains a generated starter template for a ${platform} companion shell. +function displayPlatformName(platform: CompanionShellTemplatePlatform): string { + if (platform === 'macos') { + return 'macOS'; + } + if (platform === 'ios') { + return 'iOS'; + } + return 'Android'; +} + +function readmeBody(platform: CompanionShellTemplatePlatform, templateFiles: string[]): string { + const platformName = displayPlatformName(platform); + const templateList = templateFiles.map((name) => `- \`${name}\``).join('\n'); + return `# Flynn Companion ${platformName} Shell Template + +This directory contains a generated starter template for ${platformArticle(platform)} ${platformName} companion shell. Files: - \`companion.bootstrap.json\`: resolved Flynn companion bootstrap contract -- \`${templateFilename(platform)}\`: platform-native starter model/wrapper snippet +${templateList} Notes: - These templates are intentionally minimal and should be integrated into your app project. -- Runtime transport should use Flynn gateway JSON-RPC node methods (\`node.register\`, \`node.status.set\`, \`node.location.set\`, \`node.push_token.set\`). +- Runtime transport should use Flynn gateway JSON-RPC node methods (\`node.register\`, \`node.status.set\`, \`node.location.set\`, \`node.push_token.set\`, \`agent.send\`). `; } @@ -172,16 +487,26 @@ export async function writeCompanionShellTemplate( ): Promise { await mkdir(input.outputDir, { recursive: true }); const manifestPath = `${input.outputDir}/companion.bootstrap.json`; - const templatePath = `${input.outputDir}/${templateFilename(input.platform)}`; const readmePath = `${input.outputDir}/README.md`; + const artifacts = templateArtifacts(input.platform, input.manifest); + const artifactPaths = artifacts.map((artifact) => `${input.outputDir}/${artifact.filename}`); await writeFile(manifestPath, `${JSON.stringify(input.manifest, null, 2)}\n`, 'utf8'); - await writeFile(templatePath, templateBody(input.platform, input.manifest), 'utf8'); - await writeFile(readmePath, readmeBody(input.platform), 'utf8'); + for (let i = 0; i < artifacts.length; i += 1) { + await writeFile(artifactPaths[i], artifacts[i].body, 'utf8'); + } + await writeFile( + readmePath, + readmeBody( + input.platform, + artifacts.map((artifact) => artifact.filename), + ), + 'utf8', + ); return { outputDir: input.outputDir, platform: input.platform, - files: [manifestPath, templatePath, readmePath], + files: [manifestPath, ...artifactPaths, readmePath], }; }