From d3038698663809153749a53db748982a48289486 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 20:51:00 -0800 Subject: [PATCH] feat(companion): add runnable macOS menu-bar reference app scaffold --- README.md | 5 +- apps/companion/README.md | 1 + .../android/companion.bootstrap.json | 2 +- apps/companion/ios/companion.bootstrap.json | 2 +- apps/companion/macos-app/Package.swift | 18 ++ apps/companion/macos-app/README.md | 21 ++ .../Resources/companion.bootstrap.json | 23 ++ .../Sources/FlynnCompanionMenuBar/main.swift | 218 +++++++++++++ apps/companion/macos/companion.bootstrap.json | 2 +- docs/api/PROTOCOL.md | 3 +- docs/architecture/AGENT_DIAGRAM.md | 2 +- .../GATEWAY_SESSIONS_AND_QUEUE.md | 2 +- docs/operations/COMPANION_RELEASE_BUNDLE.md | 2 + ...-personal-assistant-productization-plan.md | 4 +- docs/plans/state.json | 11 +- scripts/export-companion-reference-apps.ts | 1 + src/companion/index.ts | 5 + src/companion/macosMenuBarApp.test.ts | 50 +++ src/companion/macosMenuBarApp.ts | 304 ++++++++++++++++++ src/companion/referenceApps.test.ts | 4 + src/companion/referenceApps.ts | 28 ++ 21 files changed, 695 insertions(+), 13 deletions(-) create mode 100644 apps/companion/macos-app/Package.swift create mode 100644 apps/companion/macos-app/README.md create mode 100644 apps/companion/macos-app/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json create mode 100644 apps/companion/macos-app/Sources/FlynnCompanionMenuBar/main.swift create mode 100644 src/companion/macosMenuBarApp.test.ts create mode 100644 src/companion/macosMenuBarApp.ts diff --git a/README.md b/README.md index 54e2549..9d21ad4 100644 --- a/README.md +++ b/README.md @@ -1739,7 +1739,8 @@ Companion runtime helper: - `src/companion/shellTemplate.ts` provides `writeCompanionShellTemplate()` for emitting platform starter shells (macOS/iOS/Android native scaffold snippets + 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/referenceApps.ts` provides `generateReferenceCompanionApps()` for deterministic macOS/iOS/Android reference app starter exports. +- `src/companion/macosMenuBarApp.ts` provides `generateMacOSMenuBarApp()` for emitting a runnable Swift Package macOS menu-bar companion scaffold. +- `src/companion/referenceApps.ts` provides `generateReferenceCompanionApps()` for deterministic macOS/iOS/Android starter shells plus a runnable `macos-app` Swift Package scaffold. Minimal companion CLI: - `flynn companion --once` connects to the gateway, registers a node, publishes one heartbeat, then exits. @@ -1751,7 +1752,7 @@ Minimal companion CLI: - `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 --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 directories. +- `pnpm companion:reference-apps -- --output ./apps/companion` regenerates macOS/iOS/Android reference app starter shells plus `apps/companion/macos-app` runnable scaffold. - GitHub Actions workflow `.github/workflows/companion-release-bundle.yml` runs build-and-verify bundle automation and uploads release artifacts on manual dispatch. `run-companion.sh` verifies bundle checksums (`CHECKSUMS.sha256`) before launching `flynn companion`. diff --git a/apps/companion/README.md b/apps/companion/README.md index d220640..28f760a 100644 --- a/apps/companion/README.md +++ b/apps/companion/README.md @@ -3,6 +3,7 @@ This directory contains generated companion starter shells for: - macOS menu-bar style wrapper +- macOS runnable menu-bar app scaffold (Swift Package) - iOS shell - Android shell diff --git a/apps/companion/android/companion.bootstrap.json b/apps/companion/android/companion.bootstrap.json index 3ffca61..0d5e59d 100644 --- a/apps/companion/android/companion.bootstrap.json +++ b/apps/companion/android/companion.bootstrap.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-02-27T03:35:54.245Z", + "generatedAt": "2026-02-27T04:50:38.373Z", "gateway": { "url": "ws://127.0.0.1:18800" }, diff --git a/apps/companion/ios/companion.bootstrap.json b/apps/companion/ios/companion.bootstrap.json index f4dee50..947f6a3 100644 --- a/apps/companion/ios/companion.bootstrap.json +++ b/apps/companion/ios/companion.bootstrap.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-02-27T03:35:54.245Z", + "generatedAt": "2026-02-27T04:50:38.373Z", "gateway": { "url": "ws://127.0.0.1:18800" }, diff --git a/apps/companion/macos-app/Package.swift b/apps/companion/macos-app/Package.swift new file mode 100644 index 0000000..09bbc01 --- /dev/null +++ b/apps/companion/macos-app/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FlynnCompanionMenuBar", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "FlynnCompanionMenuBar", targets: ["FlynnCompanionMenuBar"]) + ], + targets: [ + .executableTarget( + name: "FlynnCompanionMenuBar", + resources: [.copy("Resources/companion.bootstrap.json")] + ) + ] +) diff --git a/apps/companion/macos-app/README.md b/apps/companion/macos-app/README.md new file mode 100644 index 0000000..e8a9246 --- /dev/null +++ b/apps/companion/macos-app/README.md @@ -0,0 +1,21 @@ +# Flynn macOS Companion Menu Bar App (Reference MVP) + +This is a runnable Swift Package reference app that creates a macOS menu bar companion controller. + +## Capabilities + +- Loads companion.bootstrap.json from package resources. +- Starts/stops flynn companion as a child process from menu actions. +- Runs one-shot handoff prompts (flynn companion --once --handoff ...). +- Shows process state in the menu bar title (F, F* running, F! error). + +## Build + Run + +Run: +cd +swift run FlynnCompanionMenuBar + +Prerequisites: + +- macOS with Swift toolchain. +- flynn executable available in PATH. diff --git a/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json b/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json new file mode 100644 index 0000000..cf73862 --- /dev/null +++ b/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-02-27T04:50:38.373Z", + "gateway": { + "url": "ws://127.0.0.1:18800" + }, + "node": { + "nodeId": "macos-menu-bar-reference", + "role": "companion", + "platform": "macos", + "capabilities": [ + "ui.canvas", + "node.status.write", + "node.location.write", + "node.push.register" + ] + }, + "runtime": { + "heartbeatSeconds": 30, + "handoffTimeoutMs": 120000, + "autoReconnect": true + } +} diff --git a/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/main.swift b/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/main.swift new file mode 100644 index 0000000..87d6c42 --- /dev/null +++ b/apps/companion/macos-app/Sources/FlynnCompanionMenuBar/main.swift @@ -0,0 +1,218 @@ +import AppKit +import Foundation + +struct CompanionBootstrap: Codable { + struct Gateway: Codable { + let url: String + let token: String? + } + struct Node: Codable { + let nodeId: String + let role: String + let platform: String + let capabilities: [String] + } + struct Runtime: Codable { + let heartbeatSeconds: Int + let handoffTimeoutMs: Int + let autoReconnect: Bool + } + let schemaVersion: Int + let generatedAt: String + let gateway: Gateway + let node: Node + let runtime: Runtime +} + +final class CompanionProcessController { + private var process: Process? + private var isRunning = false + private(set) var lastError: String? + private let bootstrap: CompanionBootstrap + + init(bootstrap: CompanionBootstrap) { + self.bootstrap = bootstrap + } + + func start() { + guard process == nil else { return } + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") + var args: [String] = [ + "flynn", + "companion", + "--url", bootstrap.gateway.url, + "--node-id", bootstrap.node.nodeId, + "--role", bootstrap.node.role, + "--platform", bootstrap.node.platform, + "--heartbeat", String(bootstrap.runtime.heartbeatSeconds), + "--handoff-timeout", String(bootstrap.runtime.handoffTimeoutMs), + ] + if let token = bootstrap.gateway.token, !token.isEmpty { + args += ["--token", token] + } + for capability in bootstrap.node.capabilities { + args += ["--capability", capability] + } + if !bootstrap.runtime.autoReconnect { + args.append("--once") + } + proc.arguments = args + proc.terminationHandler = { [weak self] p in + DispatchQueue.main.async { + self?.process = nil + self?.isRunning = false + if p.terminationStatus != 0 { + self?.lastError = "companion exited with status \(p.terminationStatus)" + } + } + } + + do { + try proc.run() + process = proc + isRunning = true + lastError = nil + } catch { + lastError = error.localizedDescription + process = nil + isRunning = false + } + } + + func stop() { + process?.terminate() + process = nil + isRunning = false + } + + func runHandoff(message: String) { + guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") + var args: [String] = [ + "flynn", + "companion", + "--once", + "--url", bootstrap.gateway.url, + "--node-id", bootstrap.node.nodeId, + "--role", bootstrap.node.role, + "--platform", bootstrap.node.platform, + "--handoff-timeout", String(bootstrap.runtime.handoffTimeoutMs), + "--handoff", message, + ] + if let token = bootstrap.gateway.token, !token.isEmpty { + args += ["--token", token] + } + proc.arguments = args + do { + try proc.run() + } catch { + lastError = "handoff failed: \(error.localizedDescription)" + } + } + + var running: Bool { isRunning } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem! + private var startItem: NSMenuItem! + private var stopItem: NSMenuItem! + private var handoffItem: NSMenuItem! + private var errorItem: NSMenuItem! + private var controller: CompanionProcessController! + private var refreshTimer: Timer? + + func applicationDidFinishLaunching(_ notification: Notification) { + let bootstrap = Self.loadBootstrap() + controller = CompanionProcessController(bootstrap: bootstrap) + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + let menu = NSMenu() + startItem = NSMenuItem(title: "Start Companion", action: #selector(startCompanion), keyEquivalent: "s") + stopItem = NSMenuItem(title: "Stop Companion", action: #selector(stopCompanion), keyEquivalent: "x") + handoffItem = NSMenuItem(title: "Send Handoff...", action: #selector(sendHandoff), keyEquivalent: "h") + errorItem = NSMenuItem(title: "Last error: (none)", action: nil, keyEquivalent: "") + errorItem.isEnabled = false + menu.addItem(startItem) + menu.addItem(stopItem) + menu.addItem(handoffItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(errorItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")) + startItem.target = self + stopItem.target = self + handoffItem.target = self + statusItem.menu = menu + refreshUI() + refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.refreshUI() + } + } + + func applicationWillTerminate(_ notification: Notification) { + refreshTimer?.invalidate() + controller.stop() + } + + @objc private func startCompanion() { + controller.start() + refreshUI() + } + + @objc private func stopCompanion() { + controller.stop() + refreshUI() + } + + @objc private func sendHandoff() { + let alert = NSAlert() + alert.messageText = "Companion Handoff" + alert.informativeText = "Send a one-shot handoff message through Flynn companion." + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) + input.placeholderString = "message" + alert.accessoryView = input + alert.addButton(withTitle: "Send") + alert.addButton(withTitle: "Cancel") + let response = alert.runModal() + if response == .alertFirstButtonReturn { + controller.runHandoff(message: input.stringValue) + refreshUI() + } + } + + @objc private func quitApp() { + NSApplication.shared.terminate(nil) + } + + private func refreshUI() { + if controller.running { + statusItem.button?.title = "F*" + startItem.isEnabled = false + stopItem.isEnabled = true + } else { + statusItem.button?.title = controller.lastError == nil ? "F" : "F!" + startItem.isEnabled = true + stopItem.isEnabled = false + } + let errorText = controller.lastError ?? "(none)" + errorItem.title = "Last error: \(errorText)" + } + + static func loadBootstrap() -> CompanionBootstrap { + guard let url = Bundle.module.url(forResource: "companion.bootstrap", withExtension: "json"), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(CompanionBootstrap.self, from: data) + else { + fatalError("Failed to load companion.bootstrap.json resource") + } + return decoded + } +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.setActivationPolicy(.accessory) +app.delegate = delegate +app.run() diff --git a/apps/companion/macos/companion.bootstrap.json b/apps/companion/macos/companion.bootstrap.json index 2f6f86d..d9425bf 100644 --- a/apps/companion/macos/companion.bootstrap.json +++ b/apps/companion/macos/companion.bootstrap.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-02-27T03:35:54.245Z", + "generatedAt": "2026-02-27T04:50:38.373Z", "gateway": { "url": "ws://127.0.0.1:18800" }, diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index b880291..42edfc3 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -1867,5 +1867,6 @@ For more implementation details, see: - 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 reference app exporter: `src/companion/referenceApps.ts` + `scripts/export-companion-reference-apps.ts` (regenerates in-repo platform starter app directories) +- 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 0e691df..7dd0c84 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -161,7 +161,7 @@ Gateway streaming UX signals: - `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. - `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. +- `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. - `flynn companion` can bootstrap status/location/push metadata on connect (`node.status.set` + optional `node.location.set`/`node.push_token.set`) so thin companion shells can register operational context in one launch. - Canvas artifacts are persisted by the gateway so session UI surfaces can recover after daemon restarts. diff --git a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md index bd6e046..8f8c165 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -27,7 +27,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - 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 reference app directories can be regenerated via `pnpm companion:reference-apps -- --output apps/companion` for repo-shipped starter surfaces. +- 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. - Canvas artifacts are persisted per session under the gateway data directory for UI recovery across restarts. diff --git a/docs/operations/COMPANION_RELEASE_BUNDLE.md b/docs/operations/COMPANION_RELEASE_BUNDLE.md index 4c2f58d..fefa2b6 100644 --- a/docs/operations/COMPANION_RELEASE_BUNDLE.md +++ b/docs/operations/COMPANION_RELEASE_BUNDLE.md @@ -55,6 +55,8 @@ Reference app starters can be regenerated in-repo with: pnpm companion:reference-apps -- --output ./apps/companion ``` +This also regenerates `apps/companion/macos-app`, a runnable Swift Package menu-bar reference app scaffold. + CI automation: - `.github/workflows/companion-release-bundle.yml` provides a manual-dispatch workflow that generates an ephemeral signing key, builds/verifies a bundle with `pnpm companion:bundle`, and uploads artifacts. 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 a2786bc..63cf704 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, 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, 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, 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, `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 c8688da..558c99d 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -7117,10 +7117,12 @@ "status": "completed", "date": "2026-02-27", "updated": "2026-02-27", - "summary": "Added repo-shipped companion reference app surfaces for macOS/iOS/Android. New `generateReferenceCompanionApps()` helper and `pnpm companion:reference-apps` script deterministically regenerate `apps/companion/*` starter directories from typed bootstrap contracts.", + "summary": "Added repo-shipped companion reference app surfaces for macOS/iOS/Android and a runnable macOS menu-bar Swift Package scaffold (`apps/companion/macos-app`). `generateReferenceCompanionApps()` and `pnpm companion:reference-apps` regenerate these starter directories from typed bootstrap contracts.", "files_modified": [ "src/companion/referenceApps.ts", "src/companion/referenceApps.test.ts", + "src/companion/macosMenuBarApp.ts", + "src/companion/macosMenuBarApp.test.ts", "src/companion/index.ts", "scripts/export-companion-reference-apps.ts", "package.json", @@ -7128,6 +7130,9 @@ "apps/companion/macos/MenuBarCompanion.swift", "apps/companion/macos/README.md", "apps/companion/macos/companion.bootstrap.json", + "apps/companion/macos-app/Package.swift", + "apps/companion/macos-app/README.md", + "apps/companion/macos-app/Sources/FlynnCompanionMenuBar/main.swift", "apps/companion/ios/CompanionBootstrap.swift", "apps/companion/ios/README.md", "apps/companion/ios/companion.bootstrap.json", @@ -7142,7 +7147,7 @@ "docs/plans/2026-02-26-personal-assistant-productization-plan.md", "docs/plans/state.json" ], - "test_status": "pnpm test:run src/companion/referenceApps.test.ts src/companion/shellTemplate.test.ts + pnpm typecheck passing" + "test_status": "pnpm test:run src/companion/macosMenuBarApp.test.ts src/companion/referenceApps.test.ts src/companion/shellTemplate.test.ts + pnpm typecheck passing" }, "personal-assistant-productization-phase1-companion-release-manifest-artifact": { "status": "completed", @@ -7208,7 +7213,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, 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`), 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", diff --git a/scripts/export-companion-reference-apps.ts b/scripts/export-companion-reference-apps.ts index 9934b28..21da3b6 100644 --- a/scripts/export-companion-reference-apps.ts +++ b/scripts/export-companion-reference-apps.ts @@ -26,6 +26,7 @@ async function main(): Promise { for (const entry of result.generated) { console.log(`- ${entry.platform}: ${entry.outputDir}`); } + console.log(`- macos-app: ${result.macosMenuBarAppDir}`); console.log(`- readme: ${result.readmePath}`); } diff --git a/src/companion/index.ts b/src/companion/index.ts index 50740a9..6da6b16 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -15,6 +15,7 @@ export { writeCompanionShellTemplate } from './shellTemplate.js'; export { verifyCompanionReleaseBundle } from './releaseVerify.js'; export { buildAndVerifyCompanionReleaseBundle } from './releasePipeline.js'; export { generateReferenceCompanionApps } from './referenceApps.js'; +export { generateMacOSMenuBarApp } from './macosMenuBarApp.js'; export type { CompanionRuntimeClientOptions, @@ -104,3 +105,7 @@ export type { GenerateReferenceCompanionAppsResult, GeneratedReferenceCompanionApp, } from './referenceApps.js'; +export type { + GenerateMacOSMenuBarAppInput, + GenerateMacOSMenuBarAppResult, +} from './macosMenuBarApp.js'; diff --git a/src/companion/macosMenuBarApp.test.ts b/src/companion/macosMenuBarApp.test.ts new file mode 100644 index 0000000..eb76f7a --- /dev/null +++ b/src/companion/macosMenuBarApp.test.ts @@ -0,0 +1,50 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { generateMacOSMenuBarApp } from './macosMenuBarApp.js'; + +describe('generateMacOSMenuBarApp', () => { + it('writes a runnable Swift package scaffold for menu bar companion', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'flynn-macos-menubar-app-')); + const outputDir = join(tempDir, 'macos-app'); + const result = await generateMacOSMenuBarApp({ + outputDir, + manifest: { + schemaVersion: 1, + generatedAt: '2026-02-27T00:00:00.000Z', + gateway: { url: 'ws://127.0.0.1:18800' }, + node: { + nodeId: 'macos-menu-node', + role: 'companion', + platform: 'macos', + capabilities: ['ui.canvas'], + }, + runtime: { + heartbeatSeconds: 30, + handoffTimeoutMs: 120000, + autoReconnect: true, + }, + }, + }); + + const packageSwift = await readFile(`${outputDir}/Package.swift`, 'utf8'); + const mainSwift = await readFile(`${outputDir}/Sources/FlynnCompanionMenuBar/main.swift`, 'utf8'); + const bootstrap = await readFile(`${outputDir}/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json`, 'utf8'); + const readme = await readFile(`${outputDir}/README.md`, 'utf8'); + + expect(result.files).toEqual([ + `${outputDir}/Package.swift`, + `${outputDir}/Sources/FlynnCompanionMenuBar/main.swift`, + `${outputDir}/Sources/FlynnCompanionMenuBar/Resources/companion.bootstrap.json`, + `${outputDir}/README.md`, + ]); + expect(packageSwift).toContain('FlynnCompanionMenuBar'); + expect(mainSwift).toContain('NSStatusBar.system.statusItem'); + expect(mainSwift).toContain('flynn'); + expect(JSON.parse(bootstrap)).toMatchObject({ node: { nodeId: 'macos-menu-node' } }); + expect(readme).toContain('menu bar companion'); + + await rm(tempDir, { recursive: true, force: true }); + }); +}); diff --git a/src/companion/macosMenuBarApp.ts b/src/companion/macosMenuBarApp.ts new file mode 100644 index 0000000..7207d7d --- /dev/null +++ b/src/companion/macosMenuBarApp.ts @@ -0,0 +1,304 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import type { CompanionBootstrapManifest } from './bootstrapManifest.js'; + +export interface GenerateMacOSMenuBarAppInput { + outputDir: string; + manifest: CompanionBootstrapManifest; +} + +export interface GenerateMacOSMenuBarAppResult { + outputDir: string; + files: string[]; +} + +function packageSwift(): string { + return `// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FlynnCompanionMenuBar", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "FlynnCompanionMenuBar", targets: ["FlynnCompanionMenuBar"]) + ], + targets: [ + .executableTarget( + name: "FlynnCompanionMenuBar", + resources: [.copy("Resources/companion.bootstrap.json")] + ) + ] +) +`; +} + +function readmeBody(): string { + return `# Flynn macOS Companion Menu Bar App (Reference MVP) + +This is a runnable Swift Package reference app that creates a macOS menu bar companion controller. + +## Capabilities + +- Loads companion.bootstrap.json from package resources. +- Starts/stops flynn companion as a child process from menu actions. +- Runs one-shot handoff prompts (flynn companion --once --handoff ...). +- Shows process state in the menu bar title (F, F* running, F! error). + +## Build + Run + +Run: +cd +swift run FlynnCompanionMenuBar + +Prerequisites: + +- macOS with Swift toolchain. +- flynn executable available in PATH. +`; +} + +function mainSwift(manifest: CompanionBootstrapManifest): string { + return `import AppKit +import Foundation + +struct CompanionBootstrap: Codable { + struct Gateway: Codable { + let url: String + let token: String? + } + struct Node: Codable { + let nodeId: String + let role: String + let platform: String + let capabilities: [String] + } + struct Runtime: Codable { + let heartbeatSeconds: Int + let handoffTimeoutMs: Int + let autoReconnect: Bool + } + let schemaVersion: Int + let generatedAt: String + let gateway: Gateway + let node: Node + let runtime: Runtime +} + +final class CompanionProcessController { + private var process: Process? + private var isRunning = false + private(set) var lastError: String? + private let bootstrap: CompanionBootstrap + + init(bootstrap: CompanionBootstrap) { + self.bootstrap = bootstrap + } + + func start() { + guard process == nil else { return } + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") + var args: [String] = [ + "flynn", + "companion", + "--url", bootstrap.gateway.url, + "--node-id", bootstrap.node.nodeId, + "--role", bootstrap.node.role, + "--platform", bootstrap.node.platform, + "--heartbeat", String(bootstrap.runtime.heartbeatSeconds), + "--handoff-timeout", String(bootstrap.runtime.handoffTimeoutMs), + ] + if let token = bootstrap.gateway.token, !token.isEmpty { + args += ["--token", token] + } + for capability in bootstrap.node.capabilities { + args += ["--capability", capability] + } + if !bootstrap.runtime.autoReconnect { + args.append("--once") + } + proc.arguments = args + proc.terminationHandler = { [weak self] p in + DispatchQueue.main.async { + self?.process = nil + self?.isRunning = false + if p.terminationStatus != 0 { + self?.lastError = "companion exited with status \\(p.terminationStatus)" + } + } + } + + do { + try proc.run() + process = proc + isRunning = true + lastError = nil + } catch { + lastError = error.localizedDescription + process = nil + isRunning = false + } + } + + func stop() { + process?.terminate() + process = nil + isRunning = false + } + + func runHandoff(message: String) { + guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") + var args: [String] = [ + "flynn", + "companion", + "--once", + "--url", bootstrap.gateway.url, + "--node-id", bootstrap.node.nodeId, + "--role", bootstrap.node.role, + "--platform", bootstrap.node.platform, + "--handoff-timeout", String(bootstrap.runtime.handoffTimeoutMs), + "--handoff", message, + ] + if let token = bootstrap.gateway.token, !token.isEmpty { + args += ["--token", token] + } + proc.arguments = args + do { + try proc.run() + } catch { + lastError = "handoff failed: \\(error.localizedDescription)" + } + } + + var running: Bool { isRunning } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem! + private var startItem: NSMenuItem! + private var stopItem: NSMenuItem! + private var handoffItem: NSMenuItem! + private var errorItem: NSMenuItem! + private var controller: CompanionProcessController! + private var refreshTimer: Timer? + + func applicationDidFinishLaunching(_ notification: Notification) { + let bootstrap = Self.loadBootstrap() + controller = CompanionProcessController(bootstrap: bootstrap) + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + let menu = NSMenu() + startItem = NSMenuItem(title: "Start Companion", action: #selector(startCompanion), keyEquivalent: "s") + stopItem = NSMenuItem(title: "Stop Companion", action: #selector(stopCompanion), keyEquivalent: "x") + handoffItem = NSMenuItem(title: "Send Handoff...", action: #selector(sendHandoff), keyEquivalent: "h") + errorItem = NSMenuItem(title: "Last error: (none)", action: nil, keyEquivalent: "") + errorItem.isEnabled = false + menu.addItem(startItem) + menu.addItem(stopItem) + menu.addItem(handoffItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(errorItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")) + startItem.target = self + stopItem.target = self + handoffItem.target = self + statusItem.menu = menu + refreshUI() + refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.refreshUI() + } + } + + func applicationWillTerminate(_ notification: Notification) { + refreshTimer?.invalidate() + controller.stop() + } + + @objc private func startCompanion() { + controller.start() + refreshUI() + } + + @objc private func stopCompanion() { + controller.stop() + refreshUI() + } + + @objc private func sendHandoff() { + let alert = NSAlert() + alert.messageText = "Companion Handoff" + alert.informativeText = "Send a one-shot handoff message through Flynn companion." + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) + input.placeholderString = "message" + alert.accessoryView = input + alert.addButton(withTitle: "Send") + alert.addButton(withTitle: "Cancel") + let response = alert.runModal() + if response == .alertFirstButtonReturn { + controller.runHandoff(message: input.stringValue) + refreshUI() + } + } + + @objc private func quitApp() { + NSApplication.shared.terminate(nil) + } + + private func refreshUI() { + if controller.running { + statusItem.button?.title = "F*" + startItem.isEnabled = false + stopItem.isEnabled = true + } else { + statusItem.button?.title = controller.lastError == nil ? "F" : "F!" + startItem.isEnabled = true + stopItem.isEnabled = false + } + let errorText = controller.lastError ?? "(none)" + errorItem.title = "Last error: \\(errorText)" + } + + static func loadBootstrap() -> CompanionBootstrap { + guard let url = Bundle.module.url(forResource: "companion.bootstrap", withExtension: "json"), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(CompanionBootstrap.self, from: data) + else { + fatalError("Failed to load companion.bootstrap.json resource") + } + return decoded + } +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.setActivationPolicy(.accessory) +app.delegate = delegate +app.run() +`; +} + +export async function generateMacOSMenuBarApp( + input: GenerateMacOSMenuBarAppInput, +): Promise { + const packagePath = `${input.outputDir}/Package.swift`; + const sourceDir = `${input.outputDir}/Sources/FlynnCompanionMenuBar`; + const sourcePath = `${sourceDir}/main.swift`; + const resourcesDir = `${sourceDir}/Resources`; + const resourceBootstrapPath = `${resourcesDir}/companion.bootstrap.json`; + const readmePath = `${input.outputDir}/README.md`; + + await mkdir(sourceDir, { recursive: true }); + await mkdir(resourcesDir, { recursive: true }); + await writeFile(packagePath, packageSwift(), 'utf8'); + await writeFile(sourcePath, mainSwift(input.manifest), 'utf8'); + await writeFile(resourceBootstrapPath, `${JSON.stringify(input.manifest, null, 2)}\n`, 'utf8'); + await writeFile(readmePath, readmeBody(), 'utf8'); + + return { + outputDir: input.outputDir, + files: [packagePath, sourcePath, resourceBootstrapPath, readmePath], + }; +} diff --git a/src/companion/referenceApps.test.ts b/src/companion/referenceApps.test.ts index 73e8cbf..9e587d7 100644 --- a/src/companion/referenceApps.test.ts +++ b/src/companion/referenceApps.test.ts @@ -16,14 +16,18 @@ describe('generateReferenceCompanionApps', () => { expect(result.generated.map((entry) => entry.platform)).toEqual(['macos', 'ios', 'android']); const macosTemplate = await readFile(`${outputDir}/macos/MenuBarCompanion.swift`, 'utf8'); + const macosAppMain = await readFile(`${outputDir}/macos-app/Sources/FlynnCompanionMenuBar/main.swift`, 'utf8'); const iosTemplate = await readFile(`${outputDir}/ios/CompanionBootstrap.swift`, 'utf8'); const androidTemplate = await readFile(`${outputDir}/android/CompanionBootstrap.kt`, 'utf8'); const rootReadme = await readFile(`${outputDir}/README.md`, 'utf8'); expect(macosTemplate).toContain('launchFlynnCompanion'); + expect(macosAppMain).toContain('NSStatusBar.system.statusItem'); expect(iosTemplate).toContain('CompanionBootstrap'); expect(androidTemplate).toContain('data class CompanionBootstrap'); expect(rootReadme).toContain('Companion Reference Apps'); + expect(result.macosMenuBarAppDir).toBe(`${outputDir}/macos-app`); + expect(result.macosMenuBarAppFiles.length).toBeGreaterThan(0); await rm(tempDir, { recursive: true, force: true }); }); diff --git a/src/companion/referenceApps.ts b/src/companion/referenceApps.ts index 63ce94d..1f6a4c8 100644 --- a/src/companion/referenceApps.ts +++ b/src/companion/referenceApps.ts @@ -1,6 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { writeCompanionShellTemplate } from './shellTemplate.js'; import type { CompanionBootstrapPlatform } from './bootstrapManifest.js'; +import { generateMacOSMenuBarApp } from './macosMenuBarApp.js'; export interface GenerateReferenceCompanionAppsInput { outputDir: string; @@ -17,6 +18,8 @@ export interface GeneratedReferenceCompanionApp { export interface GenerateReferenceCompanionAppsResult { rootDir: string; generated: GeneratedReferenceCompanionApp[]; + macosMenuBarAppDir: string; + macosMenuBarAppFiles: string[]; readmePath: string; } @@ -33,6 +36,7 @@ function rootReadmeBody(): string { This directory contains generated companion starter shells for: - macOS menu-bar style wrapper +- macOS runnable menu-bar app scaffold (Swift Package) - iOS shell - Android shell @@ -76,11 +80,35 @@ export async function generateReferenceCompanionApps( files: result.files, }); } + const macosManifest = { + schemaVersion: 1 as const, + generatedAt: generatedAt.toISOString(), + gateway: { + url: input.gatewayUrl, + }, + node: { + nodeId: 'macos-menu-bar-reference', + role: 'companion', + platform: 'macos' as const, + capabilities: defaultCapabilities('macos'), + }, + runtime: { + heartbeatSeconds: 30, + handoffTimeoutMs: 120000, + autoReconnect: true, + }, + }; + const macosMenuBar = await generateMacOSMenuBarApp({ + outputDir: `${input.outputDir}/macos-app`, + manifest: macosManifest, + }); const readmePath = `${input.outputDir}/README.md`; await writeFile(readmePath, rootReadmeBody(), 'utf8'); return { rootDir: input.outputDir, generated, + macosMenuBarAppDir: macosMenuBar.outputDir, + macosMenuBarAppFiles: macosMenuBar.files, readmePath, }; }