From 59c1033da08df41c6f29e8164cc9c45dcffbf991 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 18 Feb 2026 11:02:21 -0800 Subject: [PATCH] feat: add minimal companion client CLI command --- README.md | 5 + docs/plans/state.json | 19 +++- src/cli/companion.test.ts | 152 ++++++++++++++++++++++++++++++ src/cli/companion.ts | 189 ++++++++++++++++++++++++++++++++++++++ src/cli/index.test.ts | 1 + src/cli/index.ts | 2 + 6 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 src/cli/companion.test.ts create mode 100644 src/cli/companion.ts diff --git a/README.md b/README.md index 55bad64..bb15901 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Flynn provides a full CLI via the `flynn` binary (or `npx tsx src/cli/index.ts` | `flynn gmail-auth` | Authenticate with Gmail via OAuth2 | | `flynn gcal-auth` | Authenticate with Google Calendar via OAuth2 | | `flynn skills` | List/install/manage skills | +| `flynn companion` | Run a minimal companion node client against the gateway | `flynn setup` / `flynn onboard` now print a post-save channel verification checklist (start command, WebChat URL, `/status` smoke test, and channel-specific validation hints). @@ -1330,6 +1331,10 @@ Companion runtime helper: - 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. +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. + ## WebChat PWA Push Subscriptions Enable installable WebChat PWA metadata and browser push-subscription storage on the gateway: diff --git a/docs/plans/state.json b/docs/plans/state.json index 0ee1bec..67700d4 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5375,10 +5375,25 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/channels/utils.test.ts src/channels/telegram/adapter.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts + pnpm typecheck passing" + }, + "minimal-companion-client-tier-b2": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Implemented Tier B2 minimal companion client via new `flynn companion` CLI command. The command connects to gateway node RPC, registers a companion node, publishes heartbeats, streams agent typing/content events, supports config-derived URL/token defaults, and includes one-shot mode for smoke testing.", + "files_modified": [ + "src/cli/companion.ts", + "src/cli/companion.test.ts", + "src/cli/index.ts", + "src/cli/index.test.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/cli/companion.test.ts src/cli/index.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 1924, + "total_test_count": 1927, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -5398,7 +5413,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Implement Tier B2 minimal companion app client" + "next_up": "Track OpenClaw evolution regularly for inspiration and feature ideas" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/companion.test.ts b/src/cli/companion.test.ts new file mode 100644 index 0000000..286e813 --- /dev/null +++ b/src/cli/companion.test.ts @@ -0,0 +1,152 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockLoadConfigSafe, + mockGetConfigPath, + mockRuntimeCtorArgs, + mockRuntimeInstances, +} = vi.hoisted(() => { + const runtimeCtorArgs: Array<{ url: string; token?: string }> = []; + const runtimeInstances: Array<{ + connect: ReturnType; + registerNode: ReturnType; + setNodeStatus: ReturnType; + subscribeAgentStream: ReturnType; + subscribeAgentTyping: ReturnType; + disconnect: ReturnType; + }> = []; + + const loadConfigSafe = vi.fn(() => ({ + config: { + server: { + port: 18888, + token: 'config-token', + }, + }, + })); + + const getConfigPath = vi.fn(() => '/tmp/flynn-config.yaml'); + + return { + mockLoadConfigSafe: loadConfigSafe, + mockGetConfigPath: getConfigPath, + mockRuntimeCtorArgs: runtimeCtorArgs, + mockRuntimeInstances: runtimeInstances, + }; +}); + +vi.mock('./shared.js', () => ({ + loadConfigSafe: mockLoadConfigSafe, + getConfigPath: mockGetConfigPath, +})); + +vi.mock('../companion/index.js', () => ({ + CompanionRuntimeClient: class { + connect = vi.fn(async () => undefined); + registerNode = vi.fn(async ({ nodeId, role, capabilities }: { nodeId: string; role: string; capabilities: string[] }) => ({ + registered: true, + node: { id: nodeId, role }, + protocol: { serverVersion: 1, clientVersion: 1, negotiatedVersion: 1 }, + capabilities: { declared: capabilities, enabled: capabilities }, + })); + setNodeStatus = vi.fn(async () => ({ updated: true, node: { id: 'n', role: 'companion' } })); + subscribeAgentStream = vi.fn(() => () => undefined); + subscribeAgentTyping = vi.fn(() => () => undefined); + disconnect = vi.fn(() => undefined); + + constructor(opts: { url: string; token?: string }) { + mockRuntimeCtorArgs.push(opts); + mockRuntimeInstances.push(this); + } + }, +})); + +describe('companion command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRuntimeCtorArgs.length = 0; + mockRuntimeInstances.length = 0; + mockLoadConfigSafe.mockReturnValue({ + config: { + server: { + port: 18888, + token: 'config-token', + }, + }, + }); + mockGetConfigPath.mockReturnValue('/tmp/flynn-config.yaml'); + process.exitCode = undefined; + }); + + it('uses config-derived gateway url/token by default', 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']); + + expect(mockGetConfigPath).toHaveBeenCalledOnce(); + expect(mockRuntimeCtorArgs).toEqual([{ url: 'ws://127.0.0.1:18888', token: 'config-token' }]); + expect(mockRuntimeInstances[0]?.connect).toHaveBeenCalledOnce(); + expect(mockRuntimeInstances[0]?.registerNode).toHaveBeenCalledOnce(); + expect(mockRuntimeInstances[0]?.setNodeStatus).toHaveBeenCalledOnce(); + expect(mockRuntimeInstances[0]?.disconnect).toHaveBeenCalled(); + expect(errSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it('prefers explicit url/token and capability overrides', 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', + '--url', + 'ws://10.0.0.5:19000', + '--token', + 'override-token', + '--node-id', + 'test-node', + '--capability', + 'ui.canvas', + 'node.push.register', + ]); + + expect(mockRuntimeCtorArgs).toEqual([{ url: 'ws://10.0.0.5:19000', token: 'override-token' }]); + expect(mockRuntimeInstances[0]?.registerNode).toHaveBeenCalledWith(expect.objectContaining({ + nodeId: 'test-node', + capabilities: ['ui.canvas', 'node.push.register'], + })); + 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(); + const { registerCompanionCommand } = await import('./companion.js'); + registerCompanionCommand(program); + + await program.parseAsync(['node', 'test', 'companion', '--once', '--heartbeat', '0']); + + expect(errSpy).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + + errSpy.mockRestore(); + }); +}); + diff --git a/src/cli/companion.ts b/src/cli/companion.ts new file mode 100644 index 0000000..a72b310 --- /dev/null +++ b/src/cli/companion.ts @@ -0,0 +1,189 @@ +import type { Command } from 'commander'; +import { hostname } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { CompanionRuntimeClient } from '../companion/index.js'; +import type { SetNodeStatusInput } from '../companion/index.js'; +import { getConfigPath, loadConfigSafe } from './shared.js'; + +type CompanionPlatform = SetNodeStatusInput['platform']; + +interface CompanionCommandOptions { + config?: string; + url?: string; + token?: string; + nodeId?: string; + role?: string; + platform?: CompanionPlatform; + capability?: string[]; + heartbeat?: string; + once?: boolean; +} + +function resolveGatewayUrl(options: CompanionCommandOptions, configPath: string): string { + if (options.url && options.url.trim().length > 0) { + return options.url.trim(); + } + + const loaded = loadConfigSafe(configPath); + if (loaded.config) { + const port = loaded.config.server.port; + return `ws://127.0.0.1:${port}`; + } + + return 'ws://127.0.0.1:18800'; +} + +function resolveGatewayToken(options: CompanionCommandOptions, configPath: string): string | undefined { + if (options.token && options.token.trim().length > 0) { + return options.token.trim(); + } + + const loaded = loadConfigSafe(configPath); + return loaded.config?.server.token; +} + +function resolveCapabilities(platform: CompanionPlatform, provided?: string[]): string[] { + if (provided && provided.length > 0) { + return provided.map((v) => v.trim()).filter(Boolean); + } + + if (platform === 'ios' || platform === 'macos') { + return ['ui.canvas', 'node.status.write', 'node.location.write', 'node.push.register']; + } + if (platform === 'android') { + return ['ui.canvas', 'node.status.write', 'node.location.write', 'node.push.register']; + } + return ['ui.canvas', 'node.status.write']; +} + +function resolveNodeId(options: CompanionCommandOptions, platform: CompanionPlatform): string { + if (options.nodeId && options.nodeId.trim().length > 0) { + return options.nodeId.trim(); + } + return `${platform}-${hostname()}-${randomUUID().slice(0, 8)}`; +} + +function parseHeartbeatSeconds(value: string | undefined): number { + const raw = value ?? '30'; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 86_400) { + throw new Error('heartbeat must be an integer between 1 and 86400 seconds'); + } + return parsed; +} + +async function publishHeartbeat( + runtime: CompanionRuntimeClient, + platform: CompanionPlatform, +): Promise { + await runtime.setNodeStatus({ + platform, + statusText: 'heartbeat', + powerSource: 'unknown', + }); +} + +export async function runCompanionSession(options: CompanionCommandOptions): Promise { + const configPath = options.config ?? getConfigPath(); + const platform: CompanionPlatform = options.platform ?? 'macos'; + const gatewayUrl = resolveGatewayUrl(options, configPath); + const gatewayToken = resolveGatewayToken(options, configPath); + const role = options.role?.trim() || 'companion'; + const nodeId = resolveNodeId(options, platform); + const capabilities = resolveCapabilities(platform, options.capability); + const heartbeatSeconds = parseHeartbeatSeconds(options.heartbeat); + + const runtime = new CompanionRuntimeClient({ + url: gatewayUrl, + token: gatewayToken, + }); + + const stopSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + let heartbeatTimer: NodeJS.Timeout | null = null; + + const cleanup = (): void => { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + runtime.disconnect(1000, 'Companion shutting down'); + }; + + for (const signal of stopSignals) { + process.once(signal, cleanup); + } + + runtime.subscribeAgentStream((data) => { + const payload = data as { sessionId?: string; content?: string }; + const session = payload.sessionId ? ` (${payload.sessionId})` : ''; + if (payload.content) { + console.log(`[agent.stream${session}] ${payload.content}`); + } + }); + + runtime.subscribeAgentTyping((data) => { + const payload = data as { sessionId?: string; phase?: string }; + const session = payload.sessionId ? ` (${payload.sessionId})` : ''; + const phase = payload.phase ?? 'typing'; + console.log(`[agent.typing${session}] ${phase}`); + }); + + try { + await runtime.connect(); + const register = await runtime.registerNode({ + nodeId, + role, + capabilities, + }); + + await publishHeartbeat(runtime, platform); + + console.log(`Connected companion node ${register.node.id} (${platform}, role=${role})`); + console.log(`Gateway: ${gatewayUrl}`); + console.log(`Capabilities: ${capabilities.join(', ') || '(none)'}`); + + if (options.once) { + cleanup(); + return; + } + + heartbeatTimer = setInterval(() => { + void publishHeartbeat(runtime, platform).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`Heartbeat failed: ${message}`); + }); + }, heartbeatSeconds * 1000); + + await new Promise(() => { + // Keep process alive until interrupted. + }); + } catch (error) { + cleanup(); + throw error; + } +} + +export function registerCompanionCommand(program: Command): void { + program + .command('companion') + .description('Run a minimal gateway companion node client') + .option('-c, --config ', 'Config file path') + .option('--url ', 'Gateway WebSocket URL (default from config server.port)') + .option('--token ', 'Gateway auth token (default from config server.token)') + .option('--node-id ', 'Node ID to register (default: generated)') + .option('--role ', 'Node role', 'companion') + .option('--platform ', 'Node platform (macos|ios|android|linux|windows|unknown)', 'macos') + .option('--capability ', 'Capability list override') + .option('--heartbeat ', 'Heartbeat interval in seconds', '30') + .option('--once', 'Connect, register, publish one heartbeat, then exit', false) + .action(async (opts: CompanionCommandOptions) => { + try { + await runCompanionSession(opts); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Companion command failed: ${message}`); + process.exitCode = 1; + } + }); +} + diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index c43a94a..48b0afb 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -16,6 +16,7 @@ describe('CLI program', () => { expect(commandNames).toContain('backup'); expect(commandNames).toContain('setup'); expect(commandNames).toContain('onboard'); + expect(commandNames).toContain('companion'); expect(commandNames).toContain('openai-auth'); expect(commandNames).toContain('openai-key'); diff --git a/src/cli/index.ts b/src/cli/index.ts index b2e23d9..8e8aad6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -29,6 +29,7 @@ import { registerZaiAuthCommand } from './zai-auth.js'; import { registerAnthropicAuthCommand } from './anthropic-auth.js'; import { registerSkillsCommand } from './skills.js'; import { registerBackupCommand } from './backup.js'; +import { registerCompanionCommand } from './companion.js'; export function createProgram(): Command { const program = new Command(); @@ -58,6 +59,7 @@ export function createProgram(): Command { registerAnthropicAuthCommand(program); registerSkillsCommand(program); registerBackupCommand(program); + registerCompanionCommand(program); return program; }