From ee9306149639ccf700683fa799b3c3b756a945a5 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 18:55:29 -0800 Subject: [PATCH] feat(companion): add shell bootstrap status location push controls --- README.md | 5 +- docs/api/PROTOCOL.md | 4 +- docs/architecture/AGENT_DIAGRAM.md | 1 + .../GATEWAY_SESSIONS_AND_QUEUE.md | 1 + ...-personal-assistant-productization-plan.md | 2 +- docs/plans/state.json | 25 +- src/cli/companion.test.ts | 131 +++++++++ src/cli/companion.ts | 250 +++++++++++++++++- src/companion/bootstrapManifest.test.ts | 36 +++ src/companion/bootstrapManifest.ts | 16 +- 10 files changed, 458 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 77ce61b..232e2e7 100644 --- a/README.md +++ b/README.md @@ -1734,13 +1734,16 @@ Companion runtime helper: - stream passthrough helpers (`subscribeEvents`, `subscribeEvent`, `clearEventSubscriptions`, `cancelPendingEventWaits`, `listKnownEventNames`, `eventSubscriptionCount`, `subscribeAgentStream/Typing/ContextWarning`, `waitForEvent`, `waitForAnyEvent`, `waitForAgentStream/Typing/ContextWarning`) - runtime observability/control passthroughs (`pendingRequestCount`, `pendingEventWaitCount`, `hasPendingWork`, `idle`, `lastDisconnectCode`, `lastDisconnectReason`, `getPendingWorkSnapshot()`, `getEventSurfaceSnapshot()`, `getConnectionSnapshot()`, `connected`, `waitForIdle()`) - `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. +- `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. Minimal companion CLI: - `flynn companion --once` connects to the gateway, registers a node, publishes one heartbeat, then exits. - `flynn companion --platform macos --heartbeat 30` runs a long-lived node with periodic heartbeats and logs `agent.stream`/`agent.typing` events. - `flynn companion --once --handoff "summarize my status"` performs one post-registration `agent.send` handoff and prints the `done` content. - `flynn companion --export-bootstrap ./companion.bootstrap.json` writes a resolved bootstrap manifest for desktop/mobile companion app packaging (use `-` for stdout). +- `flynn companion --once --platform ios --app-version 1.2.3 --device-name "iPhone" --status-text ready --battery-pct 84 --power-source battery` sends richer initial node status metadata. +- `flynn companion --once --latitude 37.3349 --longitude -122.009 --location-source gps` bootstraps node location metadata. +- `flynn companion --once --platform android --push-token ` (or `--platform ios --push-token `) registers push routing metadata during bootstrap. ## WebChat PWA Push Subscriptions diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index e6b8294..665dff0 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -23,7 +23,7 @@ The gateway provides: - **HTTP Server**: Serves static dashboard and handles webhook endpoints - **Node Capability Negotiation**: Optional companion-node role/capability registration -Operational note: onboarding (`flynn setup` / `flynn onboard`) now runs post-save live readiness checks (model/channel/memory/automation) and prints a guided first-success task flow. Companion CLI now also supports bootstrap-manifest export (`flynn companion --export-bootstrap `) for desktop/mobile app packaging without changing JSON-RPC method/event shapes. +Operational note: onboarding (`flynn setup` / `flynn onboard`) now runs post-save live readiness checks (model/channel/memory/automation) and prints a guided first-success task flow. Companion CLI now also supports bootstrap-manifest export (`flynn companion --export-bootstrap `) plus richer shell bootstrap flags for status/location/push (`--app-version`, `--latitude/--longitude`, `--push-token`, etc.) for desktop/mobile app packaging without changing JSON-RPC method/event shapes. ### Execution Model (Sessions + Per-Session Queue) @@ -1862,4 +1862,4 @@ For more implementation details, see: - Gateway server: `src/gateway/server.ts` - Companion runtime client helper: `src/companion/runtimeClient.ts` (node + system + `canvas.*` typed RPC wrappers, optional `autoConnect`/`autoReconnect`, optional reconnect state replay, `sendAgentMessage` handoff helper, connection event subscriptions) - Platform companion wrappers: `src/companion/platformClients.ts` -- Companion bootstrap manifest helper: `src/companion/bootstrapManifest.ts` (typed packaging manifest contract used by `flynn companion --export-bootstrap`) +- Companion bootstrap manifest helper: `src/companion/bootstrapManifest.ts` (typed packaging manifest contract used by `flynn companion --export-bootstrap`, including optional initial status/location/push payloads) diff --git a/docs/architecture/AGENT_DIAGRAM.md b/docs/architecture/AGENT_DIAGRAM.md index 88f2176..bbba502 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -156,6 +156,7 @@ Gateway streaming UX signals: - Routing applies reaction rules with deterministic priority/cooldown (and recursion guard) before intent routing. - Companion nodes re-register `node.*` capabilities after reconnect; runtime clients can auto-reconnect, optionally replay cached node state (`register/status/location/push`), and surface connection events. - `flynn companion --export-bootstrap ` can emit a resolved companion bootstrap manifest (gateway/node/runtime contract) for desktop/mobile packaging flows without opening a runtime connection. +- `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. - TTS synthesis uses an ordered provider chain with health cooldown tracking; if all providers fail, replies degrade to text-only without dropping the response. - Talk mode accepts spoken/text `stop`/`cancel` while active and maps it onto the same `/stop` run-control cancellation path used for text sessions. diff --git a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md index 3f5626c..61e65c3 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -21,6 +21,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - Browser workflow reliability primitives (`browser.wait_for/assert/extract/checkpoint.*`) execute in the same queued session lane and apply browser-config guardrails (domain allowlist/high-risk confirmation, bounded retries, workflow step budget). - Companion `node.*` registration is per WebSocket connection; reconnects must re-register capabilities before invoking node RPC methods (or use runtime-client reconnect state replay to re-register/status/location/push automatically). - Companion packaging/bootstrap can be generated offline via `flynn companion --export-bootstrap `, which emits resolved gateway/node/runtime settings without opening a WebSocket session. +- 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. - TTS output is best-effort with ordered provider fallback + per-provider cooldown tracking; synthesis failures still fall back to text-only responses. - Talk mode voice sessions share the same cancel/replace semantics as text lanes (`/stop`, interrupt mode preemption), including spoken `stop`/`cancel` mapping while talk mode is active. 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 9537515..cdd82b5 100644 --- a/docs/plans/2026-02-26-personal-assistant-productization-plan.md +++ b/docs/plans/2026-02-26-personal-assistant-productization-plan.md @@ -49,7 +49,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. +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, and `flynn companion` now 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 56ace33..f6f00ec 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -6942,10 +6942,29 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/cli/companion.test.ts src/companion/bootstrapManifest.test.ts + pnpm typecheck passing" + }, + "personal-assistant-productization-phase1-companion-shell-bootstrap-controls": { + "status": "completed", + "date": "2026-02-27", + "updated": "2026-02-27", + "summary": "Extended `flynn companion` with practical shell-bootstrap controls for desktop/mobile wrappers: optional status metadata (`appVersion`/`deviceName`/`statusText`/battery/power), optional location bootstrap (`latitude`/`longitude` + metadata), and optional push-token bootstrap (`apns`/`fcm`) applied during registration/reconnect. Companion bootstrap manifest export now carries optional status/location/push payloads.", + "files_modified": [ + "src/cli/companion.ts", + "src/cli/companion.test.ts", + "src/companion/bootstrapManifest.ts", + "src/companion/bootstrapManifest.test.ts", + "README.md", + "docs/api/PROTOCOL.md", + "docs/architecture/AGENT_DIAGRAM.md", + "docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md", + "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/bootstrapManifest.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 2572, + "total_test_count": 2574, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -6960,7 +6979,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 updated 2026-02-27 (phase 3 + phase 1 + phase 2 + phase 4 slices + companion bootstrap packaging contract) — channel breadth, setup wizard, baseline browser automation, subagent controls, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), onboarding first-success funnel improvements, and companion bootstrap export for app packaging are implemented; remaining high-impact personal-assistant gaps center on shipped desktop/mobile companion app binaries and release packaging.", + "feature_gap_scorecard": "rebaselined 2026-02-26 and updated 2026-02-27 (phase 3 + phase 1 + phase 2 + phase 4 slices + companion bootstrap packaging contract + shell bootstrap controls) — channel breadth, setup wizard, baseline browser automation, subagent controls, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging bootstrap export and 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; remaining high-impact personal-assistant gaps center on shipped desktop/mobile companion app binaries and release packaging.", "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", @@ -6993,7 +7012,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": "in_progress — 8-10 week phased roadmap active; Phase 3 browser workflow reliability shipped, Phase 1 companion runtime reliability includes reconnect state replay + typed handoff support, Phase 2 voice reliability ships talk controls + TTS provider fallback/health + interruption-safe voice cancel mapping, Phase 4 onboarding includes Personal Assistant Mode preset + live readiness checks + first-success guidance, and companion bootstrap manifest export now supports desktop/mobile packaging flows. Remaining phase focus: shipped companion app surfaces and release artifacts.", + "personal_assistant_productization_plan": "in_progress — 8-10 week phased roadmap active; Phase 3 browser workflow reliability shipped, Phase 1 companion runtime reliability includes reconnect state replay + typed handoff support, companion packaging manifest export, and one-shot shell bootstrap controls for status/location/push metadata, Phase 2 voice reliability ships talk controls + TTS provider fallback/health + interruption-safe voice cancel mapping, and Phase 4 onboarding includes Personal Assistant Mode preset + live readiness checks + first-success guidance. Remaining phase focus: shipped companion app surfaces and release artifacts.", "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/cli/companion.test.ts b/src/cli/companion.test.ts index 962355b..eca11e8 100644 --- a/src/cli/companion.test.ts +++ b/src/cli/companion.test.ts @@ -16,6 +16,8 @@ const { connect: ReturnType; registerNode: ReturnType; setNodeStatus: ReturnType; + setNodeLocation: ReturnType; + setNodePushToken: ReturnType; sendAgentMessage: ReturnType; subscribeAgentStream: ReturnType; subscribeAgentTyping: ReturnType; @@ -43,6 +45,24 @@ const { heartbeatSeconds: number; handoffTimeoutMs: number; autoReconnect: boolean; + status?: { + appVersion?: string; + deviceName?: string; + statusText?: string; + batteryPct?: number; + powerSource?: string; + }; + location?: { + latitude: number; + longitude: number; + source?: string; + }; + push?: { + provider: string; + token: string; + topic?: string; + environment?: string; + }; }) => ({ schemaVersion: 1, generatedAt: '2026-02-27T00:00:00.000Z', @@ -61,6 +81,9 @@ const { handoffTimeoutMs: input.handoffTimeoutMs, autoReconnect: input.autoReconnect, }, + ...(input.status ? { status: input.status } : {}), + ...(input.location ? { location: input.location } : {}), + ...(input.push ? { push: input.push } : {}), })); return { @@ -94,6 +117,8 @@ vi.mock('../companion/index.js', () => ({ capabilities: { declared: capabilities, enabled: capabilities }, })); setNodeStatus = vi.fn(async () => ({ updated: true, node: { id: 'n', role: 'companion' } })); + setNodeLocation = vi.fn(async () => ({ updated: true, node: { id: 'n', role: 'companion' } })); + setNodePushToken = vi.fn(async () => ({ updated: true, node: { id: 'n', role: 'companion' } })); sendAgentMessage = vi.fn(async () => ({ content: 'handoff response' })); subscribeAgentStream = vi.fn(() => () => undefined); subscribeAgentTyping = vi.fn(() => () => undefined); @@ -230,6 +255,22 @@ describe('companion command', () => { 'ios-device', '--heartbeat', '45', + '--app-version', + '1.2.3', + '--status-text', + 'ready', + '--latitude', + '37.3349', + '--longitude', + '-122.009', + '--location-source', + 'gps', + '--push-token', + '0123456789abcdef0123456789abcdef', + '--push-topic', + 'com.flynn.mobile', + '--push-environment', + 'production', '--handoff-timeout', '5000', '--export-bootstrap', @@ -254,6 +295,26 @@ describe('companion command', () => { expect(manifest.runtime.heartbeatSeconds).toBe(45); expect(manifest.runtime.handoffTimeoutMs).toBe(5000); expect(mockCreateCompanionBootstrapManifest).toHaveBeenCalledOnce(); + expect(mockCreateCompanionBootstrapManifest).toHaveBeenCalledWith(expect.objectContaining({ + status: { + appVersion: '1.2.3', + statusText: 'ready', + deviceName: undefined, + batteryPct: undefined, + powerSource: undefined, + }, + location: { + latitude: 37.3349, + longitude: -122.009, + source: 'gps', + }, + push: { + provider: 'apns', + token: '0123456789abcdef0123456789abcdef', + topic: 'com.flynn.mobile', + environment: 'production', + }, + })); expect(mockRuntimeCtorArgs).toEqual([]); expect(mockRuntimeInstances).toEqual([]); expect(errSpy).not.toHaveBeenCalled(); @@ -299,6 +360,62 @@ describe('companion command', () => { errSpy.mockRestore(); }); + it('applies location, push, and status bootstrap settings to runtime registration', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const program = new Command(); + const { registerCompanionCommand } = await import('./companion.js'); + registerCompanionCommand(program); + + await program.parseAsync([ + 'node', + 'test', + 'companion', + '--once', + '--platform', + 'android', + '--battery-pct', + '73', + '--power-source', + 'battery', + '--latitude', + '40.7128', + '--longitude', + '-74.0060', + '--location-accuracy-meters', + '5', + '--push-token', + 'abcdef0123456789abcdef0123456789', + ]); + + expect(mockRuntimeInstances[0]?.setNodeStatus).toHaveBeenCalledWith(expect.objectContaining({ + platform: 'android', + batteryPct: 73, + powerSource: 'battery', + statusText: 'heartbeat', + })); + expect(mockRuntimeInstances[0]?.setNodeLocation).toHaveBeenCalledWith({ + latitude: 40.7128, + longitude: -74.006, + accuracyMeters: 5, + altitudeMeters: undefined, + headingDegrees: undefined, + speedMps: undefined, + source: undefined, + capturedAt: undefined, + }); + expect(mockRuntimeInstances[0]?.setNodePushToken).toHaveBeenCalledWith({ + provider: 'fcm', + token: 'abcdef0123456789abcdef0123456789', + topic: undefined, + environment: undefined, + }); + expect(errSpy).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + it('sets process exit code when options are invalid', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); const program = new Command(); @@ -312,4 +429,18 @@ describe('companion command', () => { errSpy.mockRestore(); }); + + it('sets process exit code when location is partially provided', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const program = new Command(); + const { registerCompanionCommand } = await import('./companion.js'); + registerCompanionCommand(program); + + await program.parseAsync(['node', 'test', 'companion', '--once', '--latitude', '1']); + + expect(errSpy).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + + errSpy.mockRestore(); + }); }); diff --git a/src/cli/companion.ts b/src/cli/companion.ts index 9f63391..92e2144 100644 --- a/src/cli/companion.ts +++ b/src/cli/companion.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import type { Command } from 'commander'; import { CompanionRuntimeClient } from '../companion/index.js'; -import type { SetNodeStatusInput } from '../companion/index.js'; +import type { SetNodeLocationInput, SetNodePushTokenInput, SetNodeStatusInput } from '../companion/index.js'; import { createCompanionBootstrapManifest } from '../companion/index.js'; import { getConfigPath, loadConfigSafe } from './shared.js'; @@ -20,6 +20,23 @@ interface CompanionCommandOptions { heartbeat?: string; handoff?: string; handoffTimeout?: string; + appVersion?: string; + deviceName?: string; + statusText?: string; + batteryPct?: string; + powerSource?: string; + latitude?: string; + longitude?: string; + locationSource?: string; + locationAccuracyMeters?: string; + locationAltitudeMeters?: string; + locationHeadingDegrees?: string; + locationSpeedMps?: string; + locationCapturedAt?: string; + pushProvider?: string; + pushToken?: string; + pushTopic?: string; + pushEnvironment?: string; exportBootstrap?: string; once?: boolean; } @@ -86,14 +103,202 @@ function parseHandoffTimeoutMs(value: string | undefined): number { return parsed; } +function parseOptionalFloat(value: string | undefined, fieldName: string): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldName} must be a number`); + } + return parsed; +} + +function parseOptionalInteger(value: string | undefined, fieldName: string): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldName} must be an integer`); + } + return parsed; +} + +function hasDefinedProperties(obj: Record): boolean { + return Object.values(obj).some((value) => value !== undefined); +} + +function resolveStatusOverrides( + options: CompanionCommandOptions, +): Omit { + const appVersion = options.appVersion?.trim(); + const deviceName = options.deviceName?.trim(); + const statusText = options.statusText?.trim(); + const batteryPct = parseOptionalFloat(options.batteryPct, 'battery-pct'); + const powerSourceRaw = options.powerSource?.trim(); + if (batteryPct !== undefined && (batteryPct < 0 || batteryPct > 100)) { + throw new Error('battery-pct must be between 0 and 100'); + } + if ( + powerSourceRaw !== undefined + && powerSourceRaw !== '' + && powerSourceRaw !== 'ac' + && powerSourceRaw !== 'battery' + && powerSourceRaw !== 'unknown' + ) { + throw new Error('power-source must be one of: ac, battery, unknown'); + } + + return { + appVersion: appVersion && appVersion.length > 0 ? appVersion : undefined, + deviceName: deviceName && deviceName.length > 0 ? deviceName : undefined, + statusText: statusText && statusText.length > 0 ? statusText : undefined, + batteryPct, + powerSource: (powerSourceRaw && powerSourceRaw.length > 0 + ? powerSourceRaw + : undefined) as SetNodeStatusInput['powerSource'] | undefined, + }; +} + +function resolveLocationInput(options: CompanionCommandOptions): SetNodeLocationInput | undefined { + const latitude = parseOptionalFloat(options.latitude, 'latitude'); + const longitude = parseOptionalFloat(options.longitude, 'longitude'); + const accuracyMeters = parseOptionalFloat(options.locationAccuracyMeters, 'location-accuracy-meters'); + const altitudeMeters = parseOptionalFloat(options.locationAltitudeMeters, 'location-altitude-meters'); + const headingDegrees = parseOptionalFloat(options.locationHeadingDegrees, 'location-heading-degrees'); + const speedMps = parseOptionalFloat(options.locationSpeedMps, 'location-speed-mps'); + const capturedAt = parseOptionalInteger(options.locationCapturedAt, 'location-captured-at'); + const sourceRaw = options.locationSource?.trim(); + + const hasLocationDetails = [ + latitude, + longitude, + accuracyMeters, + altitudeMeters, + headingDegrees, + speedMps, + capturedAt, + sourceRaw, + ].some((value) => value !== undefined && value !== ''); + + if (!hasLocationDetails) { + return undefined; + } + + if (latitude === undefined || longitude === undefined) { + throw new Error('latitude and longitude must be provided together'); + } + if (latitude < -90 || latitude > 90) { + throw new Error('latitude must be between -90 and 90'); + } + if (longitude < -180 || longitude > 180) { + throw new Error('longitude must be between -180 and 180'); + } + if (headingDegrees !== undefined && (headingDegrees < 0 || headingDegrees >= 360)) { + throw new Error('location-heading-degrees must be between 0 (inclusive) and 360 (exclusive)'); + } + if (speedMps !== undefined && speedMps < 0) { + throw new Error('location-speed-mps must be >= 0'); + } + if (accuracyMeters !== undefined && accuracyMeters < 0) { + throw new Error('location-accuracy-meters must be >= 0'); + } + if (capturedAt !== undefined && capturedAt < 0) { + throw new Error('location-captured-at must be >= 0'); + } + if ( + sourceRaw !== undefined + && sourceRaw !== '' + && sourceRaw !== 'gps' + && sourceRaw !== 'network' + && sourceRaw !== 'manual' + && sourceRaw !== 'unknown' + ) { + throw new Error('location-source must be one of: gps, network, manual, unknown'); + } + + return { + latitude, + longitude, + accuracyMeters, + altitudeMeters, + headingDegrees, + speedMps, + source: (sourceRaw && sourceRaw.length > 0 + ? sourceRaw + : undefined) as SetNodeLocationInput['source'] | undefined, + capturedAt, + }; +} + +function defaultPushProviderForPlatform( + platform: CompanionPlatform, +): SetNodePushTokenInput['provider'] | undefined { + if (platform === 'ios' || platform === 'macos') { + return 'apns'; + } + if (platform === 'android') { + return 'fcm'; + } + return undefined; +} + +function resolvePushInput( + options: CompanionCommandOptions, + platform: CompanionPlatform, +): SetNodePushTokenInput | undefined { + const pushToken = options.pushToken?.trim(); + const pushProviderRaw = options.pushProvider?.trim(); + const pushTopic = options.pushTopic?.trim(); + const pushEnvironmentRaw = options.pushEnvironment?.trim(); + + const hasPushDetails = [pushToken, pushProviderRaw, pushTopic, pushEnvironmentRaw] + .some((value) => value !== undefined && value !== ''); + + if (!hasPushDetails) { + return undefined; + } + if (!pushToken || pushToken.length < 16) { + throw new Error('push-token must be provided and be at least 16 characters'); + } + const provider = (pushProviderRaw && pushProviderRaw.length > 0 + ? pushProviderRaw + : defaultPushProviderForPlatform(platform)); + if (provider !== 'apns' && provider !== 'fcm') { + throw new Error('push-provider is required for non-mobile platforms when push-token is set'); + } + if ( + pushEnvironmentRaw !== undefined + && pushEnvironmentRaw !== '' + && pushEnvironmentRaw !== 'sandbox' + && pushEnvironmentRaw !== 'production' + ) { + throw new Error('push-environment must be one of: sandbox, production'); + } + + return { + provider, + token: pushToken, + topic: pushTopic && pushTopic.length > 0 ? pushTopic : undefined, + environment: (pushEnvironmentRaw && pushEnvironmentRaw.length > 0 + ? pushEnvironmentRaw + : undefined) as SetNodePushTokenInput['environment'] | undefined, + }; +} + async function publishHeartbeat( runtime: CompanionRuntimeClient, platform: CompanionPlatform, + statusOverrides: Omit, ): Promise { await runtime.setNodeStatus({ platform, - statusText: 'heartbeat', - powerSource: 'unknown', + appVersion: statusOverrides.appVersion, + deviceName: statusOverrides.deviceName, + statusText: statusOverrides.statusText ?? 'heartbeat', + batteryPct: statusOverrides.batteryPct, + powerSource: statusOverrides.powerSource ?? 'unknown', }); } @@ -108,6 +313,9 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro const heartbeatSeconds = parseHeartbeatSeconds(options.heartbeat); const handoffMessage = options.handoff?.trim(); const handoffTimeoutMs = parseHandoffTimeoutMs(options.handoffTimeout); + const statusOverrides = resolveStatusOverrides(options); + const locationInput = resolveLocationInput(options); + const pushInput = resolvePushInput(options, platform); const exportBootstrapPath = options.exportBootstrap?.trim(); if (exportBootstrapPath) { @@ -121,6 +329,9 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro heartbeatSeconds, handoffTimeoutMs, autoReconnect: !options.once, + status: hasDefinedProperties(statusOverrides) ? statusOverrides : undefined, + location: locationInput, + push: pushInput, }); const body = `${JSON.stringify(manifest, null, 2)}\n`; if (exportBootstrapPath === '-') { @@ -156,7 +367,7 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro return; } heartbeatTimer = setInterval(() => { - void publishHeartbeat(runtime, platform).catch((error: unknown) => { + void publishHeartbeat(runtime, platform, statusOverrides).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); console.error(`Heartbeat failed: ${message}`); }); @@ -182,12 +393,24 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro capabilities, }); - await publishHeartbeat(runtime, platform); + await publishHeartbeat(runtime, platform, statusOverrides); + if (locationInput) { + await runtime.setNodeLocation(locationInput); + } + if (pushInput) { + await runtime.setNodePushToken(pushInput); + } const verb = label === 'connected' ? 'Connected' : 'Reconnected'; console.log(`${verb} companion node ${register.node.id} (${platform}, role=${role})`); console.log(`Gateway: ${gatewayUrl}`); console.log(`Capabilities: ${capabilities.join(', ') || '(none)'}`); + if (locationInput) { + console.log(`Location bootstrap: ${locationInput.latitude},${locationInput.longitude}`); + } + if (pushInput) { + console.log(`Push bootstrap: provider=${pushInput.provider}`); + } startHeartbeat(); })(); @@ -279,6 +502,23 @@ export function registerCompanionCommand(program: Command): void { .option('--platform ', 'Node platform (macos|ios|android|linux|windows|unknown)', 'macos') .option('--capability ', 'Capability list override') .option('--heartbeat ', 'Heartbeat interval in seconds', '30') + .option('--app-version ', 'Status metadata appVersion for node.status.set') + .option('--device-name ', 'Status metadata deviceName for node.status.set') + .option('--status-text ', 'Status metadata statusText for node.status.set') + .option('--battery-pct ', 'Status metadata battery percent (0-100)') + .option('--power-source ', 'Status metadata power source (ac|battery|unknown)') + .option('--latitude ', 'Optional node location latitude') + .option('--longitude ', 'Optional node location longitude') + .option('--location-source ', 'Optional location source (gps|network|manual|unknown)') + .option('--location-accuracy-meters ', 'Optional location accuracy in meters') + .option('--location-altitude-meters ', 'Optional location altitude in meters') + .option('--location-heading-degrees ', 'Optional location heading in degrees [0,360)') + .option('--location-speed-mps ', 'Optional location speed in meters per second') + .option('--location-captured-at ', 'Optional location capturedAt epoch milliseconds') + .option('--push-provider ', 'Optional push provider (apns|fcm)') + .option('--push-token ', 'Optional push token for node.push_token.set') + .option('--push-topic ', 'Optional push topic (typically APNs bundle ID)') + .option('--push-environment ', 'Optional push environment (sandbox|production)') .option('--handoff ', 'Optional one-shot agent message handoff after registration') .option('--handoff-timeout ', 'Handoff timeout in milliseconds', '120000') .option( diff --git a/src/companion/bootstrapManifest.test.ts b/src/companion/bootstrapManifest.test.ts index 1a9a8e3..1a9edc5 100644 --- a/src/companion/bootstrapManifest.test.ts +++ b/src/companion/bootstrapManifest.test.ts @@ -13,6 +13,24 @@ describe('createCompanionBootstrapManifest', () => { heartbeatSeconds: 45, handoffTimeoutMs: 5000, autoReconnect: true, + status: { + appVersion: '1.2.3', + deviceName: 'iPhone', + statusText: 'ready', + batteryPct: 88, + powerSource: 'battery', + }, + location: { + latitude: 37.3349, + longitude: -122.009, + source: 'gps', + }, + push: { + provider: 'apns', + token: '0123456789abcdef0123456789abcdef', + topic: 'com.flynn.app', + environment: 'production', + }, generatedAt: new Date('2026-02-27T00:00:00.000Z'), }); @@ -34,6 +52,24 @@ describe('createCompanionBootstrapManifest', () => { handoffTimeoutMs: 5000, autoReconnect: true, }, + status: { + appVersion: '1.2.3', + deviceName: 'iPhone', + statusText: 'ready', + batteryPct: 88, + powerSource: 'battery', + }, + location: { + latitude: 37.3349, + longitude: -122.009, + source: 'gps', + }, + push: { + provider: 'apns', + token: '0123456789abcdef0123456789abcdef', + topic: 'com.flynn.app', + environment: 'production', + }, }); }); diff --git a/src/companion/bootstrapManifest.ts b/src/companion/bootstrapManifest.ts index bb76ea0..d022141 100644 --- a/src/companion/bootstrapManifest.ts +++ b/src/companion/bootstrapManifest.ts @@ -1,4 +1,9 @@ -import type { RegisterNodeInput, SetNodeStatusInput } from './runtimeClient.js'; +import type { + RegisterNodeInput, + SetNodeStatusInput, + SetNodeLocationInput, + SetNodePushTokenInput, +} from './runtimeClient.js'; export type CompanionBootstrapPlatform = SetNodeStatusInput['platform']; @@ -17,6 +22,9 @@ export interface CompanionBootstrapManifest { handoffTimeoutMs: number; autoReconnect: boolean; }; + status?: Omit; + location?: SetNodeLocationInput; + push?: SetNodePushTokenInput; } export interface CreateCompanionBootstrapManifestInput { @@ -29,6 +37,9 @@ export interface CreateCompanionBootstrapManifestInput { heartbeatSeconds: number; handoffTimeoutMs: number; autoReconnect: boolean; + status?: Omit; + location?: SetNodeLocationInput; + push?: SetNodePushTokenInput; generatedAt?: Date; } @@ -55,5 +66,8 @@ export function createCompanionBootstrapManifest( handoffTimeoutMs: input.handoffTimeoutMs, autoReconnect: input.autoReconnect, }, + ...(input.status ? { status: input.status } : {}), + ...(input.location ? { location: input.location } : {}), + ...(input.push ? { push: input.push } : {}), }; }