From 2cd9390e54071552cd84d749c39a482d1ae438e9 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Mon, 6 Jul 2026 07:59:20 +0200 Subject: [PATCH 01/11] feat(cli): reflex on/off schedules automatic cloud sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-relay reflex on` previously only flipped a flag and did a one-time cloud login — nothing ever pushed history to relayhistory-cloud, so "Reflex is on" never actually synced anything. Now `reflex on` also schedules the ai-hist background services (local `sync` + cloud `push`, via `ai-hist --install-service`), `reflex off` removes the push service (leaving local capture in place), and `reflex status` reports whether cloud push is scheduled. Scheduling is best-effort: if ai-hist isn't on PATH the flag still flips and a clear hint is printed, matching the existing cloud-login resilience. Wiring goes through injectable ReflexDependencies (installCloudSync / uninstallCloudSync / cloudSyncInstalled) so it stays unit-testable without shelling out. Requires ai-hist with `push --install-service` (AgentWorkforce/relayhistory#38). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/commands/reflex.test.ts | 45 +++++++++- packages/cli/src/cli/commands/reflex.ts | 88 +++++++++++++++++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cli/commands/reflex.test.ts b/packages/cli/src/cli/commands/reflex.test.ts index bd05f4428..272407067 100644 --- a/packages/cli/src/cli/commands/reflex.test.ts +++ b/packages/cli/src/cli/commands/reflex.test.ts @@ -41,6 +41,9 @@ function createHarness(overrides?: Partial) { loginToCloud: vi.fn(async () => ({ ok: true as const })), prompt: vi.fn(async () => true), log: vi.fn(() => undefined), + installCloudSync: vi.fn(async () => ({ ok: true as const })), + uninstallCloudSync: vi.fn(async () => ({ ok: true as const })), + cloudSyncInstalled: vi.fn(() => false), ...overrides, }; @@ -78,21 +81,40 @@ describe('registerReflexCommands', () => { expect(deps.prompt).toHaveBeenCalledWith('Enable Reflex? (y/N) '); expect(deps.readRelayAuth).toHaveBeenCalled(); expect(deps.loginToCloud).toHaveBeenCalledWith(FAKE_RELAY_TOKEN); + expect(deps.installCloudSync).toHaveBeenCalled(); expect(outputLines(deps)).toEqual( expect.arrayContaining([ 'Reflex will capture your agent sessions and sync to history.agentrelay.com', + 'Scheduled automatic history sync + cloud push.', 'Reflex is on.', 'State file: ~/.agentworkforce/reflex.json', ]) ); }); - it('reflex off writes disabled state and prints confirmation', async () => { + it('reflex on still enables when scheduling the sync service fails', async () => { + const installCloudSync = vi.fn(async () => ({ + ok: false as const, + error: 'ai-hist was not found on your PATH.', + })); + const { program, deps } = createHarness({ installCloudSync }); + + await program.parseAsync(['node', 'agent-relay', 'reflex', 'on']); + + expect(readState()).toEqual({ enabled: true, enabledAt: ENABLED_AT }); + expect(outputLines(deps)).toContain( + 'Reflex is enabled, but automatic sync could not be scheduled: ai-hist was not found on your PATH.' + ); + expect(outputLines(deps)).toContain('Reflex is on.'); + }); + + it('reflex off writes disabled state, removes the push service, and prints confirmation', async () => { const { program, deps } = createHarness(); await program.parseAsync(['node', 'agent-relay', 'reflex', 'off']); expect(readState()).toEqual({ enabled: false }); + expect(deps.uninstallCloudSync).toHaveBeenCalled(); expect(outputLines(deps)).toContain('Reflex is off.'); }); @@ -114,7 +136,26 @@ describe('registerReflexCommands', () => { await program.parseAsync(['node', 'agent-relay', 'reflex', 'status']); - expect(outputLines(deps)).toEqual(['Reflex is on.', `Enabled at: ${ENABLED_AT}`]); + expect(outputLines(deps)).toEqual([ + 'Reflex is on.', + `Enabled at: ${ENABLED_AT}`, + 'Cloud push service: not scheduled — run `agent-relay reflex on`.', + ]); + }); + + it('reflex status reports the cloud push service as scheduled when installed', async () => { + const cloudSyncInstalled = vi.fn(() => true); + const { program, deps } = createHarness({ cloudSyncInstalled }); + fs.mkdirSync(path.dirname(statePath()), { recursive: true }); + fs.writeFileSync( + statePath(), + JSON.stringify({ enabled: true, enabledAt: ENABLED_AT }, null, 2), + 'utf-8' + ); + + await program.parseAsync(['node', 'agent-relay', 'reflex', 'status']); + + expect(outputLines(deps)).toContain('Cloud push service: scheduled.'); }); it('reflex status with malformed JSON treats the state as absent', async () => { diff --git a/packages/cli/src/cli/commands/reflex.ts b/packages/cli/src/cli/commands/reflex.ts index 854aef86a..190d7312c 100644 --- a/packages/cli/src/cli/commands/reflex.ts +++ b/packages/cli/src/cli/commands/reflex.ts @@ -1,8 +1,10 @@ +import { execFile } from 'node:child_process'; import fs from 'node:fs'; import { chmod, mkdir, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; +import { promisify } from 'node:util'; import { Command } from 'commander'; @@ -12,6 +14,7 @@ interface ReflexState { } export type LoginCloudResult = { ok: true } | { ok: false; error: string }; +export type ServiceResult = { ok: true } | { ok: false; error: string }; export interface ReflexDependencies { fs: typeof fs; @@ -20,6 +23,12 @@ export interface ReflexDependencies { loginToCloud: (relayAccessToken: string) => Promise; prompt: (question: string) => Promise; log: (...args: unknown[]) => void; + /** Schedule automatic local sync + cloud push (ai-hist background services). */ + installCloudSync: () => Promise; + /** Remove the automatic cloud push service. */ + uninstallCloudSync: () => Promise; + /** Whether the automatic cloud push service is currently scheduled. */ + cloudSyncInstalled: () => boolean; } const ALLOWED_RELAYHISTORY_HOSTS = new Set(['history.agentrelay.com']); @@ -126,16 +135,75 @@ async function defaultLoginToCloud(relayAccessToken: string): Promise { + for (const args of CLOUD_SYNC_STAGES) { + try { + await execFileAsync('ai-hist', args); + } catch (err) { + return { ok: false, error: aiHistFailure(err, args) }; + } + } + return { ok: true }; +} + +async function defaultUninstallCloudSync(): Promise { + // Only remove cloud upload; leave local `sync` capture in place. + const args = ['push', '--uninstall-service']; + try { + await execFileAsync('ai-hist', args); + return { ok: true }; + } catch (err) { + return { ok: false, error: aiHistFailure(err, args) }; + } +} + +function defaultCloudSyncInstalled(fsImpl: typeof fs, homedir: () => string): boolean { + if (process.platform !== 'darwin') { + // On Linux the push job lives in crontab; we don't shell out just to report + // status, so report unknown (false) rather than guess. + return false; + } + return fsImpl.existsSync( + path.join(homedir(), 'Library', 'LaunchAgents', 'com.ai-hist.push.plist') + ); +} + function withDefaults(overrides: Partial = {}): ReflexDependencies { - return { + const deps: ReflexDependencies = { fs, homedir: os.homedir, readRelayAuth: defaultReadRelayAuth, loginToCloud: defaultLoginToCloud, prompt: promptYesNo, log: (...args: unknown[]) => console.log(...args), + installCloudSync: defaultInstallCloudSync, + uninstallCloudSync: defaultUninstallCloudSync, + cloudSyncInstalled: () => false, ...overrides, }; + if (!overrides.cloudSyncInstalled) { + // Probe the launchd plist using the same homedir the rest of the deps use. + deps.cloudSyncInstalled = () => defaultCloudSyncInstalled(deps.fs, deps.homedir); + } + return deps; } function getReflexDir(deps: ReflexDependencies): string { @@ -197,6 +265,13 @@ export function registerReflexCommands(program: Command, overrides: Partial { + .action(async () => { writeReflexState(deps, { enabled: false }); + const removal = await deps.uninstallCloudSync(); + if (!removal.ok) { + deps.log(`Reflex is off, but the cloud push service could not be removed: ${removal.error}`); + } deps.log('Reflex is off.'); }); @@ -224,6 +303,11 @@ export function registerReflexCommands(program: Command, overrides: Partial Date: Mon, 6 Jul 2026 06:00:29 +0000 Subject: [PATCH 02/11] style: auto-format with Prettier --- packages/cli/src/cli/commands/reflex.test.ts | 6 +----- packages/cli/src/cli/commands/reflex.ts | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/cli/commands/reflex.test.ts b/packages/cli/src/cli/commands/reflex.test.ts index 272407067..bc57f5669 100644 --- a/packages/cli/src/cli/commands/reflex.test.ts +++ b/packages/cli/src/cli/commands/reflex.test.ts @@ -147,11 +147,7 @@ describe('registerReflexCommands', () => { const cloudSyncInstalled = vi.fn(() => true); const { program, deps } = createHarness({ cloudSyncInstalled }); fs.mkdirSync(path.dirname(statePath()), { recursive: true }); - fs.writeFileSync( - statePath(), - JSON.stringify({ enabled: true, enabledAt: ENABLED_AT }, null, 2), - 'utf-8' - ); + fs.writeFileSync(statePath(), JSON.stringify({ enabled: true, enabledAt: ENABLED_AT }, null, 2), 'utf-8'); await program.parseAsync(['node', 'agent-relay', 'reflex', 'status']); diff --git a/packages/cli/src/cli/commands/reflex.ts b/packages/cli/src/cli/commands/reflex.ts index 190d7312c..31a04ddfb 100644 --- a/packages/cli/src/cli/commands/reflex.ts +++ b/packages/cli/src/cli/commands/reflex.ts @@ -181,9 +181,7 @@ function defaultCloudSyncInstalled(fsImpl: typeof fs, homedir: () => string): bo // status, so report unknown (false) rather than guess. return false; } - return fsImpl.existsSync( - path.join(homedir(), 'Library', 'LaunchAgents', 'com.ai-hist.push.plist') - ); + return fsImpl.existsSync(path.join(homedir(), 'Library', 'LaunchAgents', 'com.ai-hist.push.plist')); } function withDefaults(overrides: Partial = {}): ReflexDependencies { From e5db41860d72c2323c4cc3bce1e598b914e46b9c Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Mon, 6 Jul 2026 08:29:56 +0200 Subject: [PATCH 03/11] feat(cli): reflex syncs to cloud in-process via ai-hist SDK (no CLI shell-out) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier shell-out approach. `agent-relay reflex on` no longer spawns `ai-hist ... --install-service`; instead the long-running `agent-relay up` host pushes new local session history to relayhistory-cloud in-process, gated on the reflex flag. - `@agent-relay/config`: `reflex-config.ts` is the single source of truth for the `~/.agentworkforce/reflex.json` shape/location — `isReflexEnabled()`, `readReflexState()`, `writeReflexState()`. `reflex.ts` now uses it and drops its private copy + the CLI shell-out deps; `reflex on` = flip flag + cloud login only. - `reflex-capture.ts`: an unref'd periodic push loop (mirrors the telemetry client) started after the fleet sidecar in `broker-lifecycle.ts` and stopped (with a final flush) in `shutdownOnce`. It calls `ai-hist/cloud`'s `pushToCloud` via a lazy, non-analyzable dynamic import so the CLI does not statically depend on it — a silent no-op if ai-hist is unavailable or the user isn't authed. - Adds `ai-hist@^0.4.0` (the SDK gains `pushToCloud` in that release) and marks `ai-hist`/`sql.js` external in the esbuild library bundle. Auth is self-consistent: reflex login and the SDK push both use ~/.config/ai-hist/auth.json. Co-Authored-By: Claude Opus 4.8 --- packages/cli/package.json | 1 + packages/cli/scripts/build-cjs.mjs | 6 +- packages/cli/src/cli/commands/reflex.test.ts | 45 +----- packages/cli/src/cli/commands/reflex.ts | 140 ++---------------- packages/cli/src/cli/lib/broker-lifecycle.ts | 6 + .../cli/src/cli/lib/reflex-capture.test.ts | 83 +++++++++++ packages/cli/src/cli/lib/reflex-capture.ts | 126 ++++++++++++++++ packages/config/src/index.ts | 1 + packages/config/src/reflex-config.ts | 48 ++++++ 9 files changed, 288 insertions(+), 168 deletions(-) create mode 100644 packages/cli/src/cli/lib/reflex-capture.test.ts create mode 100644 packages/cli/src/cli/lib/reflex-capture.ts create mode 100644 packages/config/src/reflex-config.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 8174de3f1..8c8953fd3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -54,6 +54,7 @@ "@relayfile/client": "^0.10.19", "@relayflows/cli": "^1.0.1", "@xterm/headless": "^6.0.0", + "ai-hist": "^0.4.0", "commander": "^12.1.0", "dotenv": "^17.2.3", "jiti": "^2.6.1", diff --git a/packages/cli/scripts/build-cjs.mjs b/packages/cli/scripts/build-cjs.mjs index 2eb270830..b36376ca0 100644 --- a/packages/cli/scripts/build-cjs.mjs +++ b/packages/cli/scripts/build-cjs.mjs @@ -14,8 +14,10 @@ await build({ bundle: true, target: 'node18', logLevel: 'info', - // Exclude native dependencies from bundle - they're loaded dynamically at runtime. - external: ['better-sqlite3'], + // Exclude native/WASM dependencies from bundle - they're loaded dynamically + // at runtime. `ai-hist` (Reflex cloud push) pulls in the sql.js WASM runtime, + // which must resolve its .wasm from node_modules rather than the bundle. + external: ['better-sqlite3', 'ai-hist', 'sql.js'], banner: { js: "const import_meta_url = require('node:url').pathToFileURL(__filename).href;", }, diff --git a/packages/cli/src/cli/commands/reflex.test.ts b/packages/cli/src/cli/commands/reflex.test.ts index bc57f5669..602983ded 100644 --- a/packages/cli/src/cli/commands/reflex.test.ts +++ b/packages/cli/src/cli/commands/reflex.test.ts @@ -35,15 +35,11 @@ function createHarness(overrides?: Partial) { } const deps: ReflexDependencies = { - fs, - homedir: vi.fn(() => tmpHome), + homedir: vi.fn(() => tmpHome as string), readRelayAuth: vi.fn(async () => ({ accessToken: FAKE_RELAY_TOKEN })), loginToCloud: vi.fn(async () => ({ ok: true as const })), prompt: vi.fn(async () => true), log: vi.fn(() => undefined), - installCloudSync: vi.fn(async () => ({ ok: true as const })), - uninstallCloudSync: vi.fn(async () => ({ ok: true as const })), - cloudSyncInstalled: vi.fn(() => false), ...overrides, }; @@ -81,40 +77,22 @@ describe('registerReflexCommands', () => { expect(deps.prompt).toHaveBeenCalledWith('Enable Reflex? (y/N) '); expect(deps.readRelayAuth).toHaveBeenCalled(); expect(deps.loginToCloud).toHaveBeenCalledWith(FAKE_RELAY_TOKEN); - expect(deps.installCloudSync).toHaveBeenCalled(); expect(outputLines(deps)).toEqual( expect.arrayContaining([ 'Reflex will capture your agent sessions and sync to history.agentrelay.com', - 'Scheduled automatic history sync + cloud push.', 'Reflex is on.', + 'History syncs to relayhistory-cloud automatically while `agent-relay up` is running.', 'State file: ~/.agentworkforce/reflex.json', ]) ); }); - it('reflex on still enables when scheduling the sync service fails', async () => { - const installCloudSync = vi.fn(async () => ({ - ok: false as const, - error: 'ai-hist was not found on your PATH.', - })); - const { program, deps } = createHarness({ installCloudSync }); - - await program.parseAsync(['node', 'agent-relay', 'reflex', 'on']); - - expect(readState()).toEqual({ enabled: true, enabledAt: ENABLED_AT }); - expect(outputLines(deps)).toContain( - 'Reflex is enabled, but automatic sync could not be scheduled: ai-hist was not found on your PATH.' - ); - expect(outputLines(deps)).toContain('Reflex is on.'); - }); - - it('reflex off writes disabled state, removes the push service, and prints confirmation', async () => { + it('reflex off writes disabled state and prints confirmation', async () => { const { program, deps } = createHarness(); await program.parseAsync(['node', 'agent-relay', 'reflex', 'off']); expect(readState()).toEqual({ enabled: false }); - expect(deps.uninstallCloudSync).toHaveBeenCalled(); expect(outputLines(deps)).toContain('Reflex is off.'); }); @@ -136,22 +114,7 @@ describe('registerReflexCommands', () => { await program.parseAsync(['node', 'agent-relay', 'reflex', 'status']); - expect(outputLines(deps)).toEqual([ - 'Reflex is on.', - `Enabled at: ${ENABLED_AT}`, - 'Cloud push service: not scheduled — run `agent-relay reflex on`.', - ]); - }); - - it('reflex status reports the cloud push service as scheduled when installed', async () => { - const cloudSyncInstalled = vi.fn(() => true); - const { program, deps } = createHarness({ cloudSyncInstalled }); - fs.mkdirSync(path.dirname(statePath()), { recursive: true }); - fs.writeFileSync(statePath(), JSON.stringify({ enabled: true, enabledAt: ENABLED_AT }, null, 2), 'utf-8'); - - await program.parseAsync(['node', 'agent-relay', 'reflex', 'status']); - - expect(outputLines(deps)).toContain('Cloud push service: scheduled.'); + expect(outputLines(deps)).toEqual(['Reflex is on.', `Enabled at: ${ENABLED_AT}`]); }); it('reflex status with malformed JSON treats the state as absent', async () => { diff --git a/packages/cli/src/cli/commands/reflex.ts b/packages/cli/src/cli/commands/reflex.ts index 31a04ddfb..1f2fe0053 100644 --- a/packages/cli/src/cli/commands/reflex.ts +++ b/packages/cli/src/cli/commands/reflex.ts @@ -1,34 +1,19 @@ -import { execFile } from 'node:child_process'; -import fs from 'node:fs'; import { chmod, mkdir, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; -import { promisify } from 'node:util'; +import { readReflexState, writeReflexState } from '@agent-relay/config'; import { Command } from 'commander'; -interface ReflexState { - enabled: boolean; - enabledAt?: string; -} - export type LoginCloudResult = { ok: true } | { ok: false; error: string }; -export type ServiceResult = { ok: true } | { ok: false; error: string }; export interface ReflexDependencies { - fs: typeof fs; homedir: () => string; readRelayAuth: () => Promise<{ accessToken: string } | null>; loginToCloud: (relayAccessToken: string) => Promise; prompt: (question: string) => Promise; log: (...args: unknown[]) => void; - /** Schedule automatic local sync + cloud push (ai-hist background services). */ - installCloudSync: () => Promise; - /** Remove the automatic cloud push service. */ - uninstallCloudSync: () => Promise; - /** Whether the automatic cloud push service is currently scheduled. */ - cloudSyncInstalled: () => boolean; } const ALLOWED_RELAYHISTORY_HOSTS = new Set(['history.agentrelay.com']); @@ -115,7 +100,8 @@ async function defaultLoginToCloud(relayAccessToken: string): Promise { - for (const args of CLOUD_SYNC_STAGES) { - try { - await execFileAsync('ai-hist', args); - } catch (err) { - return { ok: false, error: aiHistFailure(err, args) }; - } - } - return { ok: true }; -} - -async function defaultUninstallCloudSync(): Promise { - // Only remove cloud upload; leave local `sync` capture in place. - const args = ['push', '--uninstall-service']; - try { - await execFileAsync('ai-hist', args); - return { ok: true }; - } catch (err) { - return { ok: false, error: aiHistFailure(err, args) }; - } -} - -function defaultCloudSyncInstalled(fsImpl: typeof fs, homedir: () => string): boolean { - if (process.platform !== 'darwin') { - // On Linux the push job lives in crontab; we don't shell out just to report - // status, so report unknown (false) rather than guess. - return false; - } - return fsImpl.existsSync(path.join(homedir(), 'Library', 'LaunchAgents', 'com.ai-hist.push.plist')); -} - function withDefaults(overrides: Partial = {}): ReflexDependencies { - const deps: ReflexDependencies = { - fs, + return { homedir: os.homedir, readRelayAuth: defaultReadRelayAuth, loginToCloud: defaultLoginToCloud, prompt: promptYesNo, log: (...args: unknown[]) => console.log(...args), - installCloudSync: defaultInstallCloudSync, - uninstallCloudSync: defaultUninstallCloudSync, - cloudSyncInstalled: () => false, ...overrides, }; - if (!overrides.cloudSyncInstalled) { - // Probe the launchd plist using the same homedir the rest of the deps use. - deps.cloudSyncInstalled = () => defaultCloudSyncInstalled(deps.fs, deps.homedir); - } - return deps; -} - -function getReflexDir(deps: ReflexDependencies): string { - return path.join(deps.homedir(), '.agentworkforce'); -} - -function getReflexStateFile(deps: ReflexDependencies): string { - return path.join(getReflexDir(deps), 'reflex.json'); -} - -function writeReflexState(deps: ReflexDependencies, state: ReflexState): void { - deps.fs.mkdirSync(getReflexDir(deps), { recursive: true }); - deps.fs.writeFileSync(getReflexStateFile(deps), JSON.stringify(state, null, 2), 'utf-8'); -} - -function readReflexState(deps: ReflexDependencies): ReflexState | null { - const stateFile = getReflexStateFile(deps); - if (!deps.fs.existsSync(stateFile)) { - return null; - } - - try { - return JSON.parse(deps.fs.readFileSync(stateFile, 'utf-8')) as ReflexState; - } catch { - return null; - } } export function registerReflexCommands(program: Command, overrides: Partial = {}): void { @@ -246,10 +148,13 @@ export function registerReflexCommands(program: Command, overrides: Partial { - writeReflexState(deps, { enabled: false }); - const removal = await deps.uninstallCloudSync(); - if (!removal.ok) { - deps.log(`Reflex is off, but the cloud push service could not be removed: ${removal.error}`); - } + .action(() => { + writeReflexState({ enabled: false }, deps.homedir()); deps.log('Reflex is off.'); }); @@ -290,7 +185,7 @@ export function registerReflexCommands(program: Command, overrides: Partial { - const state = readReflexState(deps); + const state = readReflexState(deps.homedir()); if (!state) { deps.log('Reflex is off (never enabled).'); return; @@ -301,11 +196,6 @@ export function registerReflexCommands(program: Command, overrides: Partial | undefined; @@ -900,6 +902,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): shutdownPromise = Promise.resolve(); } else { shutdownPromise = (async () => { + await reflexCapture?.stop(); await fleetSidecar?.stop(); await shutdownUpResources(relay, paths.dataDir, deps); })(); @@ -948,6 +951,9 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): vlog(deps, options.verbose, 'Loading teams.json and starting implicit fleet sidecar (if any)...'); const teamsConfig = deps.loadTeamsConfig(paths.projectRoot); fleetSidecar = startImplicitLocalFleetSidecar(paths, relay, options, deps, teamsConfig); + // When Reflex is enabled, periodically push new local session history to + // relayhistory-cloud in-process (no CLI shell-out). No-op when disabled. + reflexCapture = startReflexCapture({ log: (message) => deps.log(message) }); const shouldSpawn = options.spawn === true ? true : options.spawn === false ? false : Boolean(teamsConfig?.autoSpawn); diff --git a/packages/cli/src/cli/lib/reflex-capture.test.ts b/packages/cli/src/cli/lib/reflex-capture.test.ts new file mode 100644 index 000000000..45acda31d --- /dev/null +++ b/packages/cli/src/cli/lib/reflex-capture.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { startReflexCapture } from './reflex-capture.js'; + +describe('startReflexCapture', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('is a no-op when reflex is disabled', async () => { + const push = vi.fn(async () => ({ sent: 0, accepted: 0 })); + const capture = startReflexCapture({ isEnabled: () => false, push, log: () => undefined }); + + await vi.advanceTimersByTimeAsync(1_000_000); + await capture.stop(); + + expect(push).not.toHaveBeenCalled(); + }); + + it('pushes after the initial delay and again on each interval', async () => { + const push = vi.fn(async () => ({ sent: 2, accepted: 2 })); + const log = vi.fn(); + const capture = startReflexCapture({ + isEnabled: () => true, + push, + log, + initialDelayMs: 100, + intervalMs: 1000, + }); + + await vi.advanceTimersByTimeAsync(100); + expect(push).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith('[reflex] synced 2 record(s) to relayhistory-cloud'); + + await vi.advanceTimersByTimeAsync(1000); + expect(push).toHaveBeenCalledTimes(2); + + await capture.stop(); + }); + + it('stop() flushes a final batch when idle', async () => { + const push = vi.fn(async () => ({ sent: 1, accepted: 1 })); + const capture = startReflexCapture({ + isEnabled: () => true, + push, + log: () => undefined, + initialDelayMs: 100_000, + intervalMs: 100_000, + }); + + // Timers are far out, so nothing has fired yet. + expect(push).not.toHaveBeenCalled(); + + await capture.stop(); + expect(push).toHaveBeenCalledTimes(1); + }); + + it('swallows push errors and keeps running', async () => { + const push = vi + .fn<[], Promise<{ sent: number; accepted: number }>>() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue({ sent: 0, accepted: 0 }); + const log = vi.fn(); + const capture = startReflexCapture({ + isEnabled: () => true, + push, + log, + initialDelayMs: 10, + intervalMs: 100, + }); + + await vi.advanceTimersByTimeAsync(10); + expect(log).toHaveBeenCalledWith(expect.stringContaining('[reflex] cloud sync failed: boom')); + + await vi.advanceTimersByTimeAsync(100); + expect(push).toHaveBeenCalledTimes(2); + + await capture.stop(); + }); +}); diff --git a/packages/cli/src/cli/lib/reflex-capture.ts b/packages/cli/src/cli/lib/reflex-capture.ts new file mode 100644 index 000000000..1a95bd1bf --- /dev/null +++ b/packages/cli/src/cli/lib/reflex-capture.ts @@ -0,0 +1,126 @@ +/** + * Reflex in-process cloud capture. + * + * When Reflex is enabled (`agent-relay reflex on`), the long-running + * `agent-relay up` host periodically pushes new local session history to + * relayhistory-cloud via the `ai-hist` SDK — no CLI shell-out, no launchd/cron. + * Mirrors the telemetry client: an unref'd timer that never blocks the event + * loop, plus a best-effort final flush on shutdown. + * + * `ai-hist/cloud` is imported lazily (dynamic, non-analyzable spec) so the + * bundled CLI does not statically depend on it — it resolves from node_modules + * at runtime and is a silent no-op if unavailable or unauthenticated. + */ +import { isReflexEnabled } from '@agent-relay/config'; + +export interface ReflexPushResult { + sent: number; + accepted: number; +} + +export interface ReflexCaptureDeps { + /** Whether Reflex is enabled (checked once at start). */ + isEnabled: () => boolean; + /** Perform one push; resolves `null` when not authed / SDK unavailable. */ + push: () => Promise; + /** Diagnostic logger. */ + log: (message: string) => void; + /** Milliseconds between pushes. */ + intervalMs: number; + /** Delay before the first push so startup isn't blocked. */ + initialDelayMs: number; +} + +export interface RunningReflexCapture { + /** Stop the timer and flush a final batch (best-effort). */ + stop: () => Promise; +} + +const DEFAULT_INTERVAL_MS = 5 * 60_000; +const DEFAULT_INITIAL_DELAY_MS = 30_000; + +/** Load the ai-hist cloud SDK lazily; returns a push result or null. */ +async function defaultPush(): Promise { + // Non-literal spec keeps this out of the esbuild bundle; resolves at runtime. + const spec = 'ai-hist/cloud'; + let mod: { + loadStoredRelayhistoryAuth?: () => Promise; + pushToCloud?: (opts: { auth: unknown }) => Promise<{ sent?: number; accepted?: number }>; + }; + try { + mod = (await import(spec)) as typeof mod; + } catch { + return null; // ai-hist not installed + } + if (typeof mod.loadStoredRelayhistoryAuth !== 'function' || typeof mod.pushToCloud !== 'function') { + return null; + } + const auth = await mod.loadStoredRelayhistoryAuth(); + if (!auth) return null; // not authenticated yet + const report = await mod.pushToCloud({ auth }); + return { sent: report.sent ?? 0, accepted: report.accepted ?? 0 }; +} + +function withDefaults(overrides: Partial): ReflexCaptureDeps { + return { + isEnabled: isReflexEnabled, + push: defaultPush, + log: (message: string) => console.error(message), + intervalMs: DEFAULT_INTERVAL_MS, + initialDelayMs: DEFAULT_INITIAL_DELAY_MS, + ...overrides, + }; +} + +export function startReflexCapture(overrides: Partial = {}): RunningReflexCapture { + const deps = withDefaults(overrides); + + if (!deps.isEnabled()) { + return { stop: async () => undefined }; + } + + let stopped = false; + // Dedup concurrent ticks: a slow push must not overlap the next interval. + let inFlight: Promise | null = null; + + const tick = (): Promise => { + if (inFlight) return inFlight; + inFlight = (async () => { + try { + const result = await deps.push(); + if (result && result.sent > 0) { + deps.log(`[reflex] synced ${result.sent} record(s) to relayhistory-cloud`); + } + } catch (err) { + deps.log(`[reflex] cloud sync failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + inFlight = null; + } + })(); + return inFlight; + }; + + const kickoff = setTimeout(() => { + if (!stopped) void tick(); + }, deps.initialDelayMs); + const timer = setInterval(() => { + if (!stopped) void tick(); + }, deps.intervalMs); + // Don't keep the process alive just for the capture timer. + kickoff.unref?.(); + timer.unref?.(); + + return { + stop: async () => { + stopped = true; + clearTimeout(kickoff); + clearInterval(timer); + // Let an in-flight push finish, then flush one final batch. + if (inFlight) { + await inFlight; + } else { + await tick(); + } + }, + }; +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index f2fc25618..5e1e73cba 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -4,6 +4,7 @@ export * from './bridge-utils.js'; export * from './teams-config.js'; export * from './shadow-config.js'; export * from './trajectory-config.js'; +export * from './reflex-config.js'; export * from './agent-config.js'; export * from './cli-auth-config.js'; export * from './cloud-config.js'; diff --git a/packages/config/src/reflex-config.ts b/packages/config/src/reflex-config.ts new file mode 100644 index 000000000..ac70935b0 --- /dev/null +++ b/packages/config/src/reflex-config.ts @@ -0,0 +1,48 @@ +/** + * Reflex feature state. + * + * A single global toggle stored at `~/.agentworkforce/reflex.json` (NOT + * per-repo). Written by `agent-relay reflex on/off`; read by the runtime to + * decide whether to capture + push session history to relayhistory-cloud + * in-process. This module is the single source of truth for the file shape and + * location so the command and the runtime never drift. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export interface ReflexState { + enabled: boolean; + enabledAt?: string; +} + +function reflexDir(home: string = homedir()): string { + return join(home, '.agentworkforce'); +} + +/** Absolute path to the reflex state file (`~/.agentworkforce/reflex.json`). */ +export function getReflexStateFile(home: string = homedir()): string { + return join(reflexDir(home), 'reflex.json'); +} + +/** Read the reflex state, or `null` when it was never written / is malformed. */ +export function readReflexState(home: string = homedir()): ReflexState | null { + const file = getReflexStateFile(home); + if (!existsSync(file)) return null; + try { + return JSON.parse(readFileSync(file, 'utf-8')) as ReflexState; + } catch { + return null; + } +} + +/** Persist the reflex state (creates `~/.agentworkforce/` as needed). */ +export function writeReflexState(state: ReflexState, home: string = homedir()): void { + mkdirSync(reflexDir(home), { recursive: true }); + writeFileSync(getReflexStateFile(home), JSON.stringify(state, null, 2), 'utf-8'); +} + +/** Whether Reflex capture is currently enabled. */ +export function isReflexEnabled(home: string = homedir()): boolean { + return readReflexState(home)?.enabled === true; +} From cd71da6d6c802e5adb94b82dd647a35732a29a7c Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Mon, 6 Jul 2026 09:10:07 +0200 Subject: [PATCH 04/11] fix(cli): address reflex PR review + unbreak CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the `ai-hist@^0.4.0` dependency: that version isn't published yet (it ships in relayhistory#38), so `npm ci` failed with ETARGET across every CI job. The capture loop already loads `ai-hist/cloud` via a lazy dynamic import and no-ops gracefully when absent, so it's a runtime-optional peer; declare it as a real dependency once 0.4.0 is published. Reverts the now-moot esbuild external entry too. - reflex-capture: re-check `isEnabled()` on every tick so `reflex off` (or `on`) takes effect immediately in a running `agent-relay up`, and start the interval only after the initial delay so the first push can't fire before initialDelayMs when intervalMs is smaller. Adds tests for both. - reflex on: only print "History syncs automatically…" when cloud login actually succeeded, so it no longer contradicts the not-logged-in / login- failed warnings. Adds negative assertions. Co-Authored-By: Claude Opus 4.8 --- packages/cli/package.json | 1 - packages/cli/scripts/build-cjs.mjs | 6 +-- packages/cli/src/cli/commands/reflex.test.ts | 8 ++++ packages/cli/src/cli/commands/reflex.ts | 11 ++++- .../cli/src/cli/lib/reflex-capture.test.ts | 42 +++++++++++++++++++ packages/cli/src/cli/lib/reflex-capture.ts | 35 ++++++++++------ 6 files changed, 83 insertions(+), 20 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8c8953fd3..8174de3f1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -54,7 +54,6 @@ "@relayfile/client": "^0.10.19", "@relayflows/cli": "^1.0.1", "@xterm/headless": "^6.0.0", - "ai-hist": "^0.4.0", "commander": "^12.1.0", "dotenv": "^17.2.3", "jiti": "^2.6.1", diff --git a/packages/cli/scripts/build-cjs.mjs b/packages/cli/scripts/build-cjs.mjs index b36376ca0..2eb270830 100644 --- a/packages/cli/scripts/build-cjs.mjs +++ b/packages/cli/scripts/build-cjs.mjs @@ -14,10 +14,8 @@ await build({ bundle: true, target: 'node18', logLevel: 'info', - // Exclude native/WASM dependencies from bundle - they're loaded dynamically - // at runtime. `ai-hist` (Reflex cloud push) pulls in the sql.js WASM runtime, - // which must resolve its .wasm from node_modules rather than the bundle. - external: ['better-sqlite3', 'ai-hist', 'sql.js'], + // Exclude native dependencies from bundle - they're loaded dynamically at runtime. + external: ['better-sqlite3'], banner: { js: "const import_meta_url = require('node:url').pathToFileURL(__filename).href;", }, diff --git a/packages/cli/src/cli/commands/reflex.test.ts b/packages/cli/src/cli/commands/reflex.test.ts index 602983ded..ba40a2c89 100644 --- a/packages/cli/src/cli/commands/reflex.test.ts +++ b/packages/cli/src/cli/commands/reflex.test.ts @@ -165,6 +165,10 @@ describe('registerReflexCommands', () => { 'Not logged in to Agent Relay. Run `agent-relay login` first to sync Reflex history to the cloud.' ); expect(outputLines(deps)).toContain('Reflex is on.'); + // No cloud auth → don't claim automatic sync is happening. + expect(outputLines(deps)).not.toContain( + 'History syncs to relayhistory-cloud automatically while `agent-relay up` is running.' + ); }); it('reflex on when cloud login fails warns instead of treating it as complete', async () => { @@ -184,5 +188,9 @@ describe('registerReflexCommands', () => { 'Reflex is enabled locally, but cloud login did not complete: Login failed (HTTP 401): Unauthorized' ); expect(outputLines(deps)).toContain('Reflex is on.'); + // Cloud login failed → don't claim automatic sync is happening. + expect(outputLines(deps)).not.toContain( + 'History syncs to relayhistory-cloud automatically while `agent-relay up` is running.' + ); }); }); diff --git a/packages/cli/src/cli/commands/reflex.ts b/packages/cli/src/cli/commands/reflex.ts index 1f2fe0053..0c6dd876d 100644 --- a/packages/cli/src/cli/commands/reflex.ts +++ b/packages/cli/src/cli/commands/reflex.ts @@ -157,19 +157,26 @@ export function registerReflexCommands(program: Command, overrides: Partial { await capture.stop(); }); + it('stops pushing when Reflex is disabled mid-run', async () => { + let enabled = true; + const push = vi.fn(async () => ({ sent: 1, accepted: 1 })); + const capture = startReflexCapture({ + isEnabled: () => enabled, + push, + log: () => undefined, + initialDelayMs: 10, + intervalMs: 100, + }); + + await vi.advanceTimersByTimeAsync(10); // kickoff push + expect(push).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(100); // one interval push + expect(push).toHaveBeenCalledTimes(2); + + enabled = false; // `agent-relay reflex off` while `up` keeps running + await vi.advanceTimersByTimeAsync(500); // several intervals, all gated off + expect(push).toHaveBeenCalledTimes(2); + + await capture.stop(); // final flush is also gated off + expect(push).toHaveBeenCalledTimes(2); + }); + + it('does not push before the initial delay even when the interval is shorter', async () => { + const push = vi.fn(async () => ({ sent: 0, accepted: 0 })); + const capture = startReflexCapture({ + isEnabled: () => true, + push, + log: () => undefined, + initialDelayMs: 1000, + intervalMs: 50, + }); + + await vi.advanceTimersByTimeAsync(900); + expect(push).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); // reach the initial delay + expect(push).toHaveBeenCalledTimes(1); + + await capture.stop(); + }); + it('stop() flushes a final batch when idle', async () => { const push = vi.fn(async () => ({ sent: 1, accepted: 1 })); const capture = startReflexCapture({ diff --git a/packages/cli/src/cli/lib/reflex-capture.ts b/packages/cli/src/cli/lib/reflex-capture.ts index 1a95bd1bf..491c151d1 100644 --- a/packages/cli/src/cli/lib/reflex-capture.ts +++ b/packages/cli/src/cli/lib/reflex-capture.ts @@ -9,7 +9,10 @@ * * `ai-hist/cloud` is imported lazily (dynamic, non-analyzable spec) so the * bundled CLI does not statically depend on it — it resolves from node_modules - * at runtime and is a silent no-op if unavailable or unauthenticated. + * at runtime and is a silent no-op if unavailable or unauthenticated. It is an + * optional runtime dependency (a peer of the `ai-hist` SDK); once + * `ai-hist@>=0.4.0` — which ships `pushToCloud` — is published it can be + * declared as a dependency so real installs pick it up. */ import { isReflexEnabled } from '@agent-relay/config'; @@ -75,16 +78,18 @@ function withDefaults(overrides: Partial): ReflexCaptureDeps export function startReflexCapture(overrides: Partial = {}): RunningReflexCapture { const deps = withDefaults(overrides); - if (!deps.isEnabled()) { - return { stop: async () => undefined }; - } - let stopped = false; // Dedup concurrent ticks: a slow push must not overlap the next interval. let inFlight: Promise | null = null; + // The recurring interval starts only after the first (delayed) push. + let timer: ReturnType | null = null; const tick = (): Promise => { if (inFlight) return inFlight; + // Re-check enablement every tick so `agent-relay reflex off` (or `on`) + // takes effect immediately in an already-running `agent-relay up`, without + // restarting the host. + if (!deps.isEnabled()) return Promise.resolve(); inFlight = (async () => { try { const result = await deps.push(); @@ -101,21 +106,25 @@ export function startReflexCapture(overrides: Partial = {}): }; const kickoff = setTimeout(() => { - if (!stopped) void tick(); + if (stopped) return; + void tick(); + // Start the interval only now, so the first push can never fire before + // initialDelayMs regardless of how small intervalMs is. + timer = setInterval(() => { + if (!stopped) void tick(); + }, deps.intervalMs); + // Don't keep the process alive just for the capture timer. + timer.unref?.(); }, deps.initialDelayMs); - const timer = setInterval(() => { - if (!stopped) void tick(); - }, deps.intervalMs); - // Don't keep the process alive just for the capture timer. kickoff.unref?.(); - timer.unref?.(); return { stop: async () => { stopped = true; clearTimeout(kickoff); - clearInterval(timer); - // Let an in-flight push finish, then flush one final batch. + if (timer) clearInterval(timer); + // Let an in-flight push finish, then flush one final batch (a no-op if + // Reflex was disabled in the meantime — tick() re-checks). if (inFlight) { await inFlight; } else { From 15c174e1619da270c4523020b3036260bc54aed6 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Mon, 6 Jul 2026 07:16:26 +0000 Subject: [PATCH 05/11] chore: apply pr-reviewer fixes for #1233 --- package-lock.json | 112 +++++++++++++++++----------------------------- 1 file changed, 41 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index 384af8447..f02db8a29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agent-relay/monorepo", - "version": "9.1.7", + "version": "9.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent-relay/monorepo", - "version": "9.1.7", + "version": "9.2.1", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -1769,7 +1769,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -1941,7 +1940,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -1959,7 +1957,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1977,7 +1974,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1995,7 +1991,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2013,7 +2008,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2031,7 +2025,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2049,7 +2042,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2067,7 +2059,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2085,7 +2076,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2103,7 +2093,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2121,7 +2110,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2139,7 +2127,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2157,7 +2144,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2175,7 +2161,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2193,7 +2178,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2211,7 +2195,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2229,7 +2212,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2247,7 +2229,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2265,7 +2246,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2283,7 +2263,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2301,7 +2280,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2319,7 +2297,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -2337,7 +2314,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -2355,7 +2331,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2373,7 +2348,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2391,7 +2365,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -4991,7 +4964,6 @@ "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", - "dev": true, "optional": true, "engines": { "node": ">=10.0.0" @@ -5319,7 +5291,6 @@ "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", - "dev": true, "hasInstallScript": true, "optional": true, "dependencies": { @@ -7755,7 +7726,6 @@ "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -9940,44 +9910,44 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "9.1.7" + "version": "9.2.1" }, "packages/broker-darwin-arm64": { "name": "@agent-relay/broker-darwin-arm64", - "version": "9.1.7", + "version": "9.2.1", "license": "MIT" }, "packages/broker-darwin-x64": { "name": "@agent-relay/broker-darwin-x64", - "version": "9.1.7", + "version": "9.2.1", "license": "MIT" }, "packages/broker-linux-arm64": { "name": "@agent-relay/broker-linux-arm64", - "version": "9.1.7", + "version": "9.2.1", "license": "MIT" }, "packages/broker-linux-x64": { "name": "@agent-relay/broker-linux-x64", - "version": "9.1.7", + "version": "9.2.1", "license": "MIT" }, "packages/broker-win32-x64": { "name": "@agent-relay/broker-win32-x64", - "version": "9.1.7", + "version": "9.2.1", "license": "MIT" }, "packages/cli": { "name": "agent-relay", - "version": "9.1.7", + "version": "9.2.1", "license": "Apache-2.0", "dependencies": { - "@agent-relay/cloud": "9.1.7", - "@agent-relay/config": "9.1.7", - "@agent-relay/fleet": "9.1.7", - "@agent-relay/harness-driver": "9.1.7", - "@agent-relay/sdk": "9.1.7", - "@agent-relay/utils": "9.1.7", + "@agent-relay/cloud": "9.2.1", + "@agent-relay/config": "9.2.1", + "@agent-relay/fleet": "9.2.1", + "@agent-relay/harness-driver": "9.2.1", + "@agent-relay/sdk": "9.2.1", + "@agent-relay/utils": "9.2.1", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/sdk": "^5.0.5", "@relayfile/client": "^0.10.19", @@ -10047,9 +10017,9 @@ }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "9.1.7", + "version": "9.2.1", "dependencies": { - "@agent-relay/config": "9.1.7", + "@agent-relay/config": "9.2.1", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -10065,7 +10035,7 @@ }, "packages/config": { "name": "@agent-relay/config", - "version": "9.1.7", + "version": "9.2.1", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -10078,60 +10048,60 @@ }, "packages/evals": { "name": "@agent-relay/evals", - "version": "9.1.7", + "version": "9.2.1", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "9.1.7", - "@agent-relay/integration-prompts": "9.1.7" + "@agent-relay/harness-driver": "9.2.1", + "@agent-relay/integration-prompts": "9.2.1" } }, "packages/fleet": { "name": "@agent-relay/fleet", - "version": "9.1.7", + "version": "9.2.1", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "9.1.7", - "@agent-relay/harnesses": "9.1.7", - "@agent-relay/sdk": "9.1.7", + "@agent-relay/harness-driver": "9.2.1", + "@agent-relay/harnesses": "9.2.1", + "@agent-relay/sdk": "9.2.1", "zod": "^3.23.8" } }, "packages/harness-driver": { "name": "@agent-relay/harness-driver", - "version": "9.1.7", + "version": "9.2.1", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "9.1.7", + "@agent-relay/sdk": "9.2.1", "ws": "^8.18.3", "zod": "^3.23.8" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "9.1.7", - "@agent-relay/broker-darwin-x64": "9.1.7", - "@agent-relay/broker-linux-arm64": "9.1.7", - "@agent-relay/broker-linux-x64": "9.1.7", - "@agent-relay/broker-win32-x64": "9.1.7" + "@agent-relay/broker-darwin-arm64": "9.2.1", + "@agent-relay/broker-darwin-x64": "9.2.1", + "@agent-relay/broker-linux-arm64": "9.2.1", + "@agent-relay/broker-linux-x64": "9.2.1", + "@agent-relay/broker-win32-x64": "9.2.1" } }, "packages/harnesses": { "name": "@agent-relay/harnesses", - "version": "9.1.7", + "version": "9.2.1", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "9.1.7", - "@agent-relay/sdk": "9.1.7" + "@agent-relay/harness-driver": "9.2.1", + "@agent-relay/sdk": "9.2.1" } }, "packages/integration-prompts": { "name": "@agent-relay/integration-prompts", - "version": "9.1.7", + "version": "9.2.1", "license": "Apache-2.0" }, "packages/policy": { "name": "@agent-relay/policy", - "version": "9.1.7", + "version": "9.2.1", "dependencies": { - "@agent-relay/config": "9.1.7" + "@agent-relay/config": "9.2.1" }, "devDependencies": { "@types/node": "^22.19.3", @@ -10140,7 +10110,7 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "9.1.7", + "version": "9.2.1", "dependencies": { "@relaycast/sdk": "^5.0.5" }, @@ -10176,9 +10146,9 @@ }, "packages/utils": { "name": "@agent-relay/utils", - "version": "9.1.7", + "version": "9.2.1", "dependencies": { - "@agent-relay/config": "9.1.7", + "@agent-relay/config": "9.2.1", "compare-versions": "^6.1.1" }, "devDependencies": { From 664c6b3b740d962364c21167b94ac5c4db13752a Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Mon, 6 Jul 2026 13:19:09 +0200 Subject: [PATCH 06/11] fix(cli): reflex writes Rust-binary auth; capture drives the binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the SDK pivot to a binary-backed push (relayhistory sdk-ts). Since the in-process push now spawns `ai-hist push`, reflex login must persist the rth_at_ session where that binary reads it: $RELAYHISTORY_HOME/auth.json (default ~/.agentworkforce/relayhistory/auth.json), in the Rust snake_case shape (base_url/access_token/refresh_token) — not the old camelCase ~/.config/ai-hist path. The capture loop no longer pre-checks auth via the TS SDK; it just calls pushToCloud, which drives the binary and returns null when the binary is missing or the user isn't logged in. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/commands/reflex.ts | 19 +++++++++++-------- packages/cli/src/cli/lib/reflex-capture.ts | 12 ++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/cli/commands/reflex.ts b/packages/cli/src/cli/commands/reflex.ts index 0c6dd876d..44958e15d 100644 --- a/packages/cli/src/cli/commands/reflex.ts +++ b/packages/cli/src/cli/commands/reflex.ts @@ -100,17 +100,20 @@ async function defaultLoginToCloud(relayAccessToken: string): Promise { // Non-literal spec keeps this out of the esbuild bundle; resolves at runtime. const spec = 'ai-hist/cloud'; let mod: { - loadStoredRelayhistoryAuth?: () => Promise; - pushToCloud?: (opts: { auth: unknown }) => Promise<{ sent?: number; accepted?: number }>; + pushToCloud?: () => Promise<{ sent?: number; accepted?: number } | null>; }; try { mod = (await import(spec)) as typeof mod; } catch { return null; // ai-hist not installed } - if (typeof mod.loadStoredRelayhistoryAuth !== 'function' || typeof mod.pushToCloud !== 'function') { + if (typeof mod.pushToCloud !== 'function') { return null; } - const auth = await mod.loadStoredRelayhistoryAuth(); - if (!auth) return null; // not authenticated yet - const report = await mod.pushToCloud({ auth }); + // pushToCloud drives the `ai-hist push` binary, which handles auth itself and + // resolves to null when the binary is missing or the user isn't logged in. + const report = await mod.pushToCloud(); + if (!report) return null; return { sent: report.sent ?? 0, accepted: report.accepted ?? 0 }; } From a84fae11e0fb8737adf211c7ae0d17efe55082a1 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 7 Jul 2026 11:26:33 +0200 Subject: [PATCH 07/11] feat(cli): reflex capture syncs+pushes via the bundled ai-hist binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toward `agent-relay reflex on` "just working" with no extra user commands: - Add `ai-hist-path.ts`: resolves the ai-hist binary like the broker resolves its own — `$AI_HIST_RUST_BIN` -> the per-platform optional-dep package (`ai-hist-bin--`) -> the install.sh location -> `ai-hist` on PATH. Once the binary ships as an optional dependency, a plain agent-relay install has it with zero setup. - Rework the capture loop to drive that binary directly: it now runs `ai-hist sync` (populate the local DB from the user's agent history) then `ai-hist push --json` each tick — previously it only pushed, so a fresh machine had nothing to upload. Dropped the lazy `ai-hist/cloud` npm import, so relay has no dependency to publish/resolve; unavailable binary or missing auth is a silent no-op. Tests cover the resolver (override + package-name mapping) and sync→push (happy path, binary-unavailable skip, not-authenticated, hard failure). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/lib/ai-hist-path.test.ts | 46 +++++++ packages/cli/src/cli/lib/ai-hist-path.ts | 76 ++++++++++++ .../cli/src/cli/lib/reflex-capture.test.ts | 69 ++++++++++- packages/cli/src/cli/lib/reflex-capture.ts | 112 +++++++++++++----- 4 files changed, 273 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/cli/lib/ai-hist-path.test.ts create mode 100644 packages/cli/src/cli/lib/ai-hist-path.ts diff --git a/packages/cli/src/cli/lib/ai-hist-path.test.ts b/packages/cli/src/cli/lib/ai-hist-path.test.ts new file mode 100644 index 000000000..9846d4ccc --- /dev/null +++ b/packages/cli/src/cli/lib/ai-hist-path.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { aiHistOptionalDepName, getAiHistBinaryPath } from './ai-hist-path.js'; + +describe('aiHistOptionalDepName', () => { + it('maps platform/arch to the per-platform package name', () => { + expect(aiHistOptionalDepName('darwin', 'arm64')).toBe('ai-hist-bin-darwin-arm64'); + expect(aiHistOptionalDepName('linux', 'x64')).toBe('ai-hist-bin-linux-x64'); + }); +}); + +describe('getAiHistBinaryPath', () => { + let tmp: string | undefined; + const prev = process.env.AI_HIST_RUST_BIN; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'aihist-bin-')); + }); + afterEach(() => { + if (prev === undefined) delete process.env.AI_HIST_RUST_BIN; + else process.env.AI_HIST_RUST_BIN = prev; + if (tmp) rmSync(tmp, { recursive: true, force: true }); + }); + + it('honors an existing $AI_HIST_RUST_BIN override', () => { + const bin = join(tmp as string, 'ai-hist'); + writeFileSync(bin, '#!/bin/sh\n'); + chmodSync(bin, 0o755); + process.env.AI_HIST_RUST_BIN = bin; + expect(getAiHistBinaryPath()).toBe(bin); + }); + + it('ignores a non-existent override (never returns the bad path)', () => { + const missing = join(tmp as string, 'does-not-exist'); + process.env.AI_HIST_RUST_BIN = missing; + // Falls through to bundled package / install-path / the `ai-hist` command — + // exact result is environment-dependent, but never the missing override. + const resolved = getAiHistBinaryPath(); + expect(resolved).not.toBe(missing); + expect(typeof resolved).toBe('string'); + }); +}); diff --git a/packages/cli/src/cli/lib/ai-hist-path.ts b/packages/cli/src/cli/lib/ai-hist-path.ts new file mode 100644 index 000000000..a017d407d --- /dev/null +++ b/packages/cli/src/cli/lib/ai-hist-path.ts @@ -0,0 +1,76 @@ +/** + * Resolve the `ai-hist` Rust binary at runtime. + * + * Mirrors the broker's resolution model so Reflex capture works on a plain + * `agent-relay` install with no extra setup: the binary ships as a + * per-platform optional-dependency package (`ai-hist-bin--`), + * auto-installed by npm, and is found here. + * + * Search order: + * 1. `$AI_HIST_RUST_BIN` explicit override + * 2. the bundled per-platform optional-dep package (primary production path) + * 3. the install.sh location (`~/.local/share/ai-hist/ai-hist-rust-bin`) + * 4. `ai-hist` on `PATH` (last resort; spawn surfaces ENOENT as a no-op) + */ +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const BINARY_NAME = 'ai-hist'; + +/** npm package that carries the prebuilt binary for a given platform/arch. */ +export function aiHistOptionalDepName( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + return `ai-hist-bin-${platform}-${arch}`; +} + +function resolutionReferences(): string[] { + const refs: string[] = []; + try { + // ESM: locate this module so it can resolve its sibling optional dep. + refs.push(fileURLToPath(import.meta.url)); + } catch { + /* not ESM / unavailable */ + } + if (process.argv[1]) refs.push(process.argv[1]); + refs.push(join(process.cwd(), 'package.json')); + return [...new Set(refs)]; +} + +/** The binary inside the platform optional-dep package, if installed. */ +function bundledBinaryPath(): string | null { + const pkg = aiHistOptionalDepName(); + const file = process.platform === 'win32' ? `${BINARY_NAME}.exe` : BINARY_NAME; + for (const ref of resolutionReferences()) { + try { + const pkgJson = createRequire(ref).resolve(`${pkg}/package.json`); + const bin = join(dirname(pkgJson), 'bin', file); + if (existsSync(bin)) return bin; + } catch { + /* try the next reference */ + } + } + return null; +} + +/** + * Resolve the ai-hist binary. Always returns a command/path; when nothing is + * discovered it falls back to `ai-hist` and lets spawn surface ENOENT (which + * the capture loop treats as a no-op). + */ +export function getAiHistBinaryPath(): string { + const override = process.env.AI_HIST_RUST_BIN; + if (override && existsSync(resolve(override))) return resolve(override); + + const bundled = bundledBinaryPath(); + if (bundled) return bundled; + + const installed = join(homedir(), '.local', 'share', 'ai-hist', 'ai-hist-rust-bin'); + if (existsSync(installed)) return installed; + + return BINARY_NAME; +} diff --git a/packages/cli/src/cli/lib/reflex-capture.test.ts b/packages/cli/src/cli/lib/reflex-capture.test.ts index 30cc6dd7c..6664d4dd2 100644 --- a/packages/cli/src/cli/lib/reflex-capture.test.ts +++ b/packages/cli/src/cli/lib/reflex-capture.test.ts @@ -1,6 +1,36 @@ +import { EventEmitter } from 'node:events'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { startReflexCapture } from './reflex-capture.js'; +import { reflexSyncAndPush, startReflexCapture } from './reflex-capture.js'; + +/** Fake child process that scripts one run's stdout/stderr/exit (or an error). */ +function makeChild(script: { stdout?: string; stderr?: string; code?: number; errorCode?: string }) { + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + if (script.errorCode) { + const err = new Error('spawn failed') as NodeJS.ErrnoException; + err.code = script.errorCode; + child.emit('error', err); + return; + } + if (script.stdout) child.stdout.emit('data', script.stdout); + if (script.stderr) child.stderr.emit('data', script.stderr); + child.emit('close', script.code ?? 0); + }); + return child; +} + +function fakeSpawn(byArgs: (args: string[]) => Parameters[0]) { + const calls: string[][] = []; + const spawnFn = ((_bin: string, args: string[]) => { + calls.push(args); + return makeChild(byArgs(args)); + }) as unknown as typeof import('node:child_process').spawn; + return { spawnFn, calls }; +} describe('startReflexCapture', () => { beforeEach(() => { @@ -123,3 +153,40 @@ describe('startReflexCapture', () => { await capture.stop(); }); }); + +describe('reflexSyncAndPush', () => { + // Real timers here: the fake child emits via setImmediate. + it('syncs then pushes and parses the report', async () => { + const { spawnFn, calls } = fakeSpawn((args) => + args[0] === 'sync' + ? { code: 0 } + : { code: 0, stdout: JSON.stringify({ sent: 2, accepted: 2, batchId: 'b', cursor: {} }) } + ); + + const result = await reflexSyncAndPush({ binPath: '/bin/echo', spawnFn }); + + expect(result).toEqual({ sent: 2, accepted: 2 }); + expect(calls).toEqual([['sync'], ['push', '--json']]); + }); + + it('returns null and skips push when the binary is unavailable', async () => { + const { spawnFn, calls } = fakeSpawn(() => ({ errorCode: 'ENOENT' })); + const result = await reflexSyncAndPush({ binPath: '/bin/echo', spawnFn }); + expect(result).toBeNull(); + expect(calls).toEqual([['sync']]); // push never attempted + }); + + it('returns null when not authenticated', async () => { + const { spawnFn } = fakeSpawn((args) => + args[0] === 'sync' ? { code: 0 } : { code: 1, stderr: 'error: not authenticated' } + ); + expect(await reflexSyncAndPush({ binPath: '/bin/echo', spawnFn })).toBeNull(); + }); + + it('rejects on other push failures', async () => { + const { spawnFn } = fakeSpawn((args) => + args[0] === 'sync' ? { code: 0 } : { code: 2, stderr: 'boom' } + ); + await expect(reflexSyncAndPush({ binPath: '/bin/echo', spawnFn })).rejects.toThrow(/exit 2.*boom/); + }); +}); diff --git a/packages/cli/src/cli/lib/reflex-capture.ts b/packages/cli/src/cli/lib/reflex-capture.ts index ce005cef6..0988f0be3 100644 --- a/packages/cli/src/cli/lib/reflex-capture.ts +++ b/packages/cli/src/cli/lib/reflex-capture.ts @@ -2,20 +2,22 @@ * Reflex in-process cloud capture. * * When Reflex is enabled (`agent-relay reflex on`), the long-running - * `agent-relay up` host periodically pushes new local session history to - * relayhistory-cloud via the `ai-hist` SDK — no CLI shell-out, no launchd/cron. - * Mirrors the telemetry client: an unref'd timer that never blocks the event - * loop, plus a best-effort final flush on shutdown. + * `agent-relay up` host periodically syncs local agent history into the ai-hist + * DB and pushes new records to relayhistory-cloud — no launchd/cron, no CLI the + * user runs by hand. Mirrors the telemetry client: an unref'd timer that never + * blocks the event loop, plus a best-effort final flush on shutdown. * - * `ai-hist/cloud` is imported lazily (dynamic, non-analyzable spec) so the - * bundled CLI does not statically depend on it — it resolves from node_modules - * at runtime and is a silent no-op if unavailable or unauthenticated. It is an - * optional runtime dependency (a peer of the `ai-hist` SDK); once - * `ai-hist@>=0.4.0` — which ships `pushToCloud` — is published it can be - * declared as a dependency so real installs pick it up. + * It drives the `ai-hist` Rust binary, which ships as a per-platform + * optional-dependency package (resolved via `getAiHistBinaryPath`) so a plain + * `agent-relay` install works with no extra setup. Everything is a silent no-op + * when the binary is unavailable or the user isn't authenticated. */ +import { spawn } from 'node:child_process'; + import { isReflexEnabled } from '@agent-relay/config'; +import { getAiHistBinaryPath } from './ai-hist-path.js'; + export interface ReflexPushResult { sent: number; accepted: number; @@ -42,32 +44,84 @@ export interface RunningReflexCapture { const DEFAULT_INTERVAL_MS = 5 * 60_000; const DEFAULT_INITIAL_DELAY_MS = 30_000; -/** Load the ai-hist cloud SDK lazily; returns a push result or null. */ -async function defaultPush(): Promise { - // Non-literal spec keeps this out of the esbuild bundle; resolves at runtime. - const spec = 'ai-hist/cloud'; - let mod: { - pushToCloud?: () => Promise<{ sent?: number; accepted?: number } | null>; - }; - try { - mod = (await import(spec)) as typeof mod; - } catch { - return null; // ai-hist not installed +interface RunResult { + code: number | null; + stdout: string; + stderr: string; +} + +/** Spawn the ai-hist binary; resolves null when it's unavailable. */ +function runAiHist(bin: string, args: string[], spawnFn: typeof spawn): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawnFn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (c) => { + stdout += String(c); + }); + child.stderr?.on('data', (c) => { + stderr += String(c); + }); + child.on('error', (err: NodeJS.ErrnoException) => { + // Binary missing / not executable / a directory → nothing to do. + if (['ENOENT', 'EACCES', 'EPERM', 'ENOTDIR', 'EISDIR'].includes(err.code ?? '')) { + resolvePromise(null); + return; + } + reject(err); + }); + child.on('close', (code) => resolvePromise({ code, stdout, stderr })); + }); +} + +export interface ReflexPushOptions { + binPath?: string; + spawnFn?: typeof spawn; +} + +/** + * Sync local agent history into the ai-hist DB, then push new records to + * relayhistory-cloud — both by driving the bundled `ai-hist` binary. This is + * what makes `reflex on` "just work": no separate ai-hist install, no CLI the + * user runs by hand. Resolves null when the binary is unavailable or the user + * isn't authenticated. + */ +export async function reflexSyncAndPush(opts: ReflexPushOptions = {}): Promise { + const bin = opts.binPath ?? getAiHistBinaryPath(); + const spawnFn = opts.spawnFn ?? spawn; + + // 1. Populate the local DB from the user's agent history. If this can't run + // (binary unavailable) there's nothing to push. + const synced = await runAiHist(bin, ['sync'], spawnFn); + if (synced === null) return null; + + // 2. Upload new records. + const pushed = await runAiHist(bin, ['push', '--json'], spawnFn); + if (pushed === null) return null; + if (pushed.code !== 0) { + // Not logged in yet is expected before `reflex on` completes. + if (/not authenticated|no relayhistory auth|run `?ai-hist login/i.test(pushed.stderr)) { + return null; + } + throw new Error(`ai-hist push failed (exit ${pushed.code}): ${pushed.stderr.trim().slice(0, 300)}`); } - if (typeof mod.pushToCloud !== 'function') { - return null; + try { + const parsed = (pushed.stdout.trim() ? JSON.parse(pushed.stdout) : {}) as { + sent?: number; + accepted?: number; + }; + return { sent: parsed.sent ?? 0, accepted: parsed.accepted ?? 0 }; + } catch (err) { + throw new Error( + `could not parse ai-hist push output: ${err instanceof Error ? err.message : String(err)}` + ); } - // pushToCloud drives the `ai-hist push` binary, which handles auth itself and - // resolves to null when the binary is missing or the user isn't logged in. - const report = await mod.pushToCloud(); - if (!report) return null; - return { sent: report.sent ?? 0, accepted: report.accepted ?? 0 }; } function withDefaults(overrides: Partial): ReflexCaptureDeps { return { isEnabled: isReflexEnabled, - push: defaultPush, + push: () => reflexSyncAndPush(), log: (message: string) => console.error(message), intervalMs: DEFAULT_INTERVAL_MS, initialDelayMs: DEFAULT_INITIAL_DELAY_MS, From b4b86acb5d636c240083c346624627fca02d0a4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Jul 2026 09:27:32 +0000 Subject: [PATCH 08/11] style: auto-format with Prettier --- packages/cli/src/cli/lib/reflex-capture.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/cli/lib/reflex-capture.test.ts b/packages/cli/src/cli/lib/reflex-capture.test.ts index 6664d4dd2..d45e0b6cd 100644 --- a/packages/cli/src/cli/lib/reflex-capture.test.ts +++ b/packages/cli/src/cli/lib/reflex-capture.test.ts @@ -184,9 +184,7 @@ describe('reflexSyncAndPush', () => { }); it('rejects on other push failures', async () => { - const { spawnFn } = fakeSpawn((args) => - args[0] === 'sync' ? { code: 0 } : { code: 2, stderr: 'boom' } - ); + const { spawnFn } = fakeSpawn((args) => (args[0] === 'sync' ? { code: 0 } : { code: 2, stderr: 'boom' })); await expect(reflexSyncAndPush({ binPath: '/bin/echo', spawnFn })).rejects.toThrow(/exit 2.*boom/); }); }); From 97930aa81ac845fde98c6a68b2da7567ef673be4 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 7 Jul 2026 11:58:19 +0200 Subject: [PATCH 09/11] feat(cli): reflex capture runs in-process via napi (no subprocess) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the spawn-based capture with an in-process call to the `ai-hist-native` napi addon's `syncAndPush()` — no `ai-hist` subprocess at all, per the requirement not to shell out to the CLI. The addon is lazy-loaded via a non-analyzable dynamic import (so it stays out of the esbuild bundle and resolves from its per-platform optional-dependency package), and is a silent no-op when unavailable or the user isn't authenticated. Removes ai-hist-path.ts (the binary resolver) and the spawn plumbing. Tests now inject the native addon instead of a fake child process. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/lib/ai-hist-path.test.ts | 46 -------- packages/cli/src/cli/lib/ai-hist-path.ts | 76 ------------- .../cli/src/cli/lib/reflex-capture.test.ts | 70 +++--------- packages/cli/src/cli/lib/reflex-capture.ts | 107 ++++++------------ 4 files changed, 49 insertions(+), 250 deletions(-) delete mode 100644 packages/cli/src/cli/lib/ai-hist-path.test.ts delete mode 100644 packages/cli/src/cli/lib/ai-hist-path.ts diff --git a/packages/cli/src/cli/lib/ai-hist-path.test.ts b/packages/cli/src/cli/lib/ai-hist-path.test.ts deleted file mode 100644 index 9846d4ccc..000000000 --- a/packages/cli/src/cli/lib/ai-hist-path.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { aiHistOptionalDepName, getAiHistBinaryPath } from './ai-hist-path.js'; - -describe('aiHistOptionalDepName', () => { - it('maps platform/arch to the per-platform package name', () => { - expect(aiHistOptionalDepName('darwin', 'arm64')).toBe('ai-hist-bin-darwin-arm64'); - expect(aiHistOptionalDepName('linux', 'x64')).toBe('ai-hist-bin-linux-x64'); - }); -}); - -describe('getAiHistBinaryPath', () => { - let tmp: string | undefined; - const prev = process.env.AI_HIST_RUST_BIN; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), 'aihist-bin-')); - }); - afterEach(() => { - if (prev === undefined) delete process.env.AI_HIST_RUST_BIN; - else process.env.AI_HIST_RUST_BIN = prev; - if (tmp) rmSync(tmp, { recursive: true, force: true }); - }); - - it('honors an existing $AI_HIST_RUST_BIN override', () => { - const bin = join(tmp as string, 'ai-hist'); - writeFileSync(bin, '#!/bin/sh\n'); - chmodSync(bin, 0o755); - process.env.AI_HIST_RUST_BIN = bin; - expect(getAiHistBinaryPath()).toBe(bin); - }); - - it('ignores a non-existent override (never returns the bad path)', () => { - const missing = join(tmp as string, 'does-not-exist'); - process.env.AI_HIST_RUST_BIN = missing; - // Falls through to bundled package / install-path / the `ai-hist` command — - // exact result is environment-dependent, but never the missing override. - const resolved = getAiHistBinaryPath(); - expect(resolved).not.toBe(missing); - expect(typeof resolved).toBe('string'); - }); -}); diff --git a/packages/cli/src/cli/lib/ai-hist-path.ts b/packages/cli/src/cli/lib/ai-hist-path.ts deleted file mode 100644 index a017d407d..000000000 --- a/packages/cli/src/cli/lib/ai-hist-path.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Resolve the `ai-hist` Rust binary at runtime. - * - * Mirrors the broker's resolution model so Reflex capture works on a plain - * `agent-relay` install with no extra setup: the binary ships as a - * per-platform optional-dependency package (`ai-hist-bin--`), - * auto-installed by npm, and is found here. - * - * Search order: - * 1. `$AI_HIST_RUST_BIN` explicit override - * 2. the bundled per-platform optional-dep package (primary production path) - * 3. the install.sh location (`~/.local/share/ai-hist/ai-hist-rust-bin`) - * 4. `ai-hist` on `PATH` (last resort; spawn surfaces ENOENT as a no-op) - */ -import { existsSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const BINARY_NAME = 'ai-hist'; - -/** npm package that carries the prebuilt binary for a given platform/arch. */ -export function aiHistOptionalDepName( - platform: NodeJS.Platform = process.platform, - arch: string = process.arch -): string { - return `ai-hist-bin-${platform}-${arch}`; -} - -function resolutionReferences(): string[] { - const refs: string[] = []; - try { - // ESM: locate this module so it can resolve its sibling optional dep. - refs.push(fileURLToPath(import.meta.url)); - } catch { - /* not ESM / unavailable */ - } - if (process.argv[1]) refs.push(process.argv[1]); - refs.push(join(process.cwd(), 'package.json')); - return [...new Set(refs)]; -} - -/** The binary inside the platform optional-dep package, if installed. */ -function bundledBinaryPath(): string | null { - const pkg = aiHistOptionalDepName(); - const file = process.platform === 'win32' ? `${BINARY_NAME}.exe` : BINARY_NAME; - for (const ref of resolutionReferences()) { - try { - const pkgJson = createRequire(ref).resolve(`${pkg}/package.json`); - const bin = join(dirname(pkgJson), 'bin', file); - if (existsSync(bin)) return bin; - } catch { - /* try the next reference */ - } - } - return null; -} - -/** - * Resolve the ai-hist binary. Always returns a command/path; when nothing is - * discovered it falls back to `ai-hist` and lets spawn surface ENOENT (which - * the capture loop treats as a no-op). - */ -export function getAiHistBinaryPath(): string { - const override = process.env.AI_HIST_RUST_BIN; - if (override && existsSync(resolve(override))) return resolve(override); - - const bundled = bundledBinaryPath(); - if (bundled) return bundled; - - const installed = join(homedir(), '.local', 'share', 'ai-hist', 'ai-hist-rust-bin'); - if (existsSync(installed)) return installed; - - return BINARY_NAME; -} diff --git a/packages/cli/src/cli/lib/reflex-capture.test.ts b/packages/cli/src/cli/lib/reflex-capture.test.ts index d45e0b6cd..490d3171b 100644 --- a/packages/cli/src/cli/lib/reflex-capture.test.ts +++ b/packages/cli/src/cli/lib/reflex-capture.test.ts @@ -1,37 +1,7 @@ -import { EventEmitter } from 'node:events'; - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { reflexSyncAndPush, startReflexCapture } from './reflex-capture.js'; -/** Fake child process that scripts one run's stdout/stderr/exit (or an error). */ -function makeChild(script: { stdout?: string; stderr?: string; code?: number; errorCode?: string }) { - const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - setImmediate(() => { - if (script.errorCode) { - const err = new Error('spawn failed') as NodeJS.ErrnoException; - err.code = script.errorCode; - child.emit('error', err); - return; - } - if (script.stdout) child.stdout.emit('data', script.stdout); - if (script.stderr) child.stderr.emit('data', script.stderr); - child.emit('close', script.code ?? 0); - }); - return child; -} - -function fakeSpawn(byArgs: (args: string[]) => Parameters[0]) { - const calls: string[][] = []; - const spawnFn = ((_bin: string, args: string[]) => { - calls.push(args); - return makeChild(byArgs(args)); - }) as unknown as typeof import('node:child_process').spawn; - return { spawnFn, calls }; -} - describe('startReflexCapture', () => { beforeEach(() => { vi.useFakeTimers(); @@ -155,36 +125,26 @@ describe('startReflexCapture', () => { }); describe('reflexSyncAndPush', () => { - // Real timers here: the fake child emits via setImmediate. - it('syncs then pushes and parses the report', async () => { - const { spawnFn, calls } = fakeSpawn((args) => - args[0] === 'sync' - ? { code: 0 } - : { code: 0, stdout: JSON.stringify({ sent: 2, accepted: 2, batchId: 'b', cursor: {} }) } - ); - - const result = await reflexSyncAndPush({ binPath: '/bin/echo', spawnFn }); - - expect(result).toEqual({ sent: 2, accepted: 2 }); - expect(calls).toEqual([['sync'], ['push', '--json']]); + it('returns the report when authenticated', async () => { + const native = { syncAndPush: async () => ({ sent: 2, accepted: 2, authenticated: true }) }; + expect(await reflexSyncAndPush({ native })).toEqual({ sent: 2, accepted: 2 }); }); - it('returns null and skips push when the binary is unavailable', async () => { - const { spawnFn, calls } = fakeSpawn(() => ({ errorCode: 'ENOENT' })); - const result = await reflexSyncAndPush({ binPath: '/bin/echo', spawnFn }); - expect(result).toBeNull(); - expect(calls).toEqual([['sync']]); // push never attempted + it('no-ops when not authenticated', async () => { + const native = { syncAndPush: async () => ({ sent: 0, accepted: 0, authenticated: false }) }; + expect(await reflexSyncAndPush({ native })).toBeNull(); }); - it('returns null when not authenticated', async () => { - const { spawnFn } = fakeSpawn((args) => - args[0] === 'sync' ? { code: 0 } : { code: 1, stderr: 'error: not authenticated' } - ); - expect(await reflexSyncAndPush({ binPath: '/bin/echo', spawnFn })).toBeNull(); + it('no-ops when the native addon is unavailable', async () => { + expect(await reflexSyncAndPush({ native: null })).toBeNull(); }); - it('rejects on other push failures', async () => { - const { spawnFn } = fakeSpawn((args) => (args[0] === 'sync' ? { code: 0 } : { code: 2, stderr: 'boom' })); - await expect(reflexSyncAndPush({ binPath: '/bin/echo', spawnFn })).rejects.toThrow(/exit 2.*boom/); + it('propagates native errors', async () => { + const native = { + syncAndPush: async () => { + throw new Error('boom'); + }, + }; + await expect(reflexSyncAndPush({ native })).rejects.toThrow(/boom/); }); }); diff --git a/packages/cli/src/cli/lib/reflex-capture.ts b/packages/cli/src/cli/lib/reflex-capture.ts index 0988f0be3..821edfb8f 100644 --- a/packages/cli/src/cli/lib/reflex-capture.ts +++ b/packages/cli/src/cli/lib/reflex-capture.ts @@ -4,20 +4,17 @@ * When Reflex is enabled (`agent-relay reflex on`), the long-running * `agent-relay up` host periodically syncs local agent history into the ai-hist * DB and pushes new records to relayhistory-cloud — no launchd/cron, no CLI the - * user runs by hand. Mirrors the telemetry client: an unref'd timer that never - * blocks the event loop, plus a best-effort final flush on shutdown. + * user runs by hand, and **no subprocess**. Mirrors the telemetry client: an + * unref'd timer that never blocks the event loop, plus a best-effort final + * flush on shutdown. * - * It drives the `ai-hist` Rust binary, which ships as a per-platform - * optional-dependency package (resolved via `getAiHistBinaryPath`) so a plain - * `agent-relay` install works with no extra setup. Everything is a silent no-op - * when the binary is unavailable or the user isn't authenticated. + * The work runs in-process through the `ai-hist-native` napi addon + * (`syncAndPush()`), which ships as a per-platform optional-dependency package + * so a plain `agent-relay` install works with no extra setup. Everything is a + * silent no-op when the addon is unavailable or the user isn't authenticated. */ -import { spawn } from 'node:child_process'; - import { isReflexEnabled } from '@agent-relay/config'; -import { getAiHistBinaryPath } from './ai-hist-path.js'; - export interface ReflexPushResult { sent: number; accepted: number; @@ -26,7 +23,7 @@ export interface ReflexPushResult { export interface ReflexCaptureDeps { /** Whether Reflex is enabled (checked once at start). */ isEnabled: () => boolean; - /** Perform one push; resolves `null` when not authed / SDK unavailable. */ + /** Perform one push; resolves `null` when not authed / addon unavailable. */ push: () => Promise; /** Diagnostic logger. */ log: (message: string) => void; @@ -44,78 +41,42 @@ export interface RunningReflexCapture { const DEFAULT_INTERVAL_MS = 5 * 60_000; const DEFAULT_INITIAL_DELAY_MS = 30_000; -interface RunResult { - code: number | null; - stdout: string; - stderr: string; +/** The native addon surface we depend on. */ +export interface NativeAiHist { + syncAndPush: () => Promise<{ sent: number; accepted: number; authenticated: boolean }>; } -/** Spawn the ai-hist binary; resolves null when it's unavailable. */ -function runAiHist(bin: string, args: string[], spawnFn: typeof spawn): Promise { - return new Promise((resolvePromise, reject) => { - const child = spawnFn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout?.on('data', (c) => { - stdout += String(c); - }); - child.stderr?.on('data', (c) => { - stderr += String(c); - }); - child.on('error', (err: NodeJS.ErrnoException) => { - // Binary missing / not executable / a directory → nothing to do. - if (['ENOENT', 'EACCES', 'EPERM', 'ENOTDIR', 'EISDIR'].includes(err.code ?? '')) { - resolvePromise(null); - return; - } - reject(err); - }); - child.on('close', (code) => resolvePromise({ code, stdout, stderr })); - }); +/** Lazily load the `ai-hist-native` napi addon; null if unavailable. */ +async function loadNative(): Promise { + // Non-literal spec keeps the native addon out of the esbuild bundle; it + // resolves from node_modules (the per-platform optional dep) at runtime. + const spec = 'ai-hist-native'; + try { + const mod = (await import(spec)) as Partial & { default?: Partial }; + const fn = mod.syncAndPush ?? mod.default?.syncAndPush; + return typeof fn === 'function' ? { syncAndPush: fn } : null; + } catch { + return null; // addon not installed for this platform + } } export interface ReflexPushOptions { - binPath?: string; - spawnFn?: typeof spawn; + /** Injectable native addon (tests); defaults to lazy-loading `ai-hist-native`. */ + native?: NativeAiHist | null; } /** - * Sync local agent history into the ai-hist DB, then push new records to - * relayhistory-cloud — both by driving the bundled `ai-hist` binary. This is - * what makes `reflex on` "just work": no separate ai-hist install, no CLI the - * user runs by hand. Resolves null when the binary is unavailable or the user - * isn't authenticated. + * Sync local agent history into the ai-hist DB and push new records to + * relayhistory-cloud, **in-process** via the native addon — no subprocess. This + * is what makes `reflex on` "just work": no separate ai-hist install, no CLI. + * Resolves null when the addon is unavailable or the user isn't authenticated. */ export async function reflexSyncAndPush(opts: ReflexPushOptions = {}): Promise { - const bin = opts.binPath ?? getAiHistBinaryPath(); - const spawnFn = opts.spawnFn ?? spawn; - - // 1. Populate the local DB from the user's agent history. If this can't run - // (binary unavailable) there's nothing to push. - const synced = await runAiHist(bin, ['sync'], spawnFn); - if (synced === null) return null; - - // 2. Upload new records. - const pushed = await runAiHist(bin, ['push', '--json'], spawnFn); - if (pushed === null) return null; - if (pushed.code !== 0) { - // Not logged in yet is expected before `reflex on` completes. - if (/not authenticated|no relayhistory auth|run `?ai-hist login/i.test(pushed.stderr)) { - return null; - } - throw new Error(`ai-hist push failed (exit ${pushed.code}): ${pushed.stderr.trim().slice(0, 300)}`); - } - try { - const parsed = (pushed.stdout.trim() ? JSON.parse(pushed.stdout) : {}) as { - sent?: number; - accepted?: number; - }; - return { sent: parsed.sent ?? 0, accepted: parsed.accepted ?? 0 }; - } catch (err) { - throw new Error( - `could not parse ai-hist push output: ${err instanceof Error ? err.message : String(err)}` - ); - } + const native = opts.native !== undefined ? opts.native : await loadNative(); + if (!native) return null; + const result = await native.syncAndPush(); + if (!result.authenticated) return null; // not logged in yet + return { sent: result.sent, accepted: result.accepted }; } function withDefaults(overrides: Partial): ReflexCaptureDeps { From 5d581932c06d74bee419a46af4703a7946c53bf3 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 7 Jul 2026 21:28:40 +0200 Subject: [PATCH 10/11] feat(cli): depend on ai-hist-native for in-process reflex capture Now that ai-hist-native@0.4.1 is published, declare it as an optional dependency so a plain `agent-relay` install pulls the addon (and npm auto-selects the matching per-platform binary via os/cpu). The reflex capture loop loads it and calls syncAndPush() in-process; it stays a graceful no-op if the addon isn't available for a platform. Verified: npm install resolves ai-hist-native + ai-hist-native-darwin-arm64 and `require('ai-hist-native')` exposes syncAndPush. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 147 ++++++++++++++++++++++++++++++++++++++ packages/cli/package.json | 3 + 2 files changed, 150 insertions(+) diff --git a/package-lock.json b/package-lock.json index f02db8a29..598f131b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1769,6 +1769,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -1940,6 +1941,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1957,6 +1959,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1974,6 +1977,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1991,6 +1995,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -2008,6 +2013,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -2025,6 +2031,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -2042,6 +2049,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2059,6 +2067,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2076,6 +2085,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2093,6 +2103,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2110,6 +2121,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2127,6 +2139,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2144,6 +2157,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2161,6 +2175,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2178,6 +2193,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2195,6 +2211,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2212,6 +2229,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2229,6 +2247,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2246,6 +2265,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2263,6 +2283,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2280,6 +2301,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2297,6 +2319,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -2314,6 +2337,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -2331,6 +2355,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2348,6 +2373,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2365,6 +2391,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -4680,6 +4707,120 @@ "node": ">=20.0.0" } }, + "node_modules/ai-hist-native": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native/-/ai-hist-native-0.4.1.tgz", + "integrity": "sha512-KXe5eATUMRzsZMFx0580zEM+F1qG4K/JF1elSJ/mFo2rYWmsdWw4Nao7n5S28P4ySn+EwOQ6FNCRRpvm7oYCMA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "ai-hist-native-darwin-arm64": "0.4.1", + "ai-hist-native-darwin-x64": "0.4.1", + "ai-hist-native-linux-arm64-gnu": "0.4.1", + "ai-hist-native-linux-arm64-musl": "0.4.1", + "ai-hist-native-linux-x64-gnu": "0.4.1", + "ai-hist-native-linux-x64-musl": "0.4.1" + } + }, + "node_modules/ai-hist-native-darwin-arm64": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-darwin-arm64/-/ai-hist-native-darwin-arm64-0.4.1.tgz", + "integrity": "sha512-4P39HLRFH67XOniP355KMrZAvInR0wJf+BgKIZvLdSuS5yWPhOJt7DQPnMgBjsYP5W0Exo5hOjOS8Bx9yBeuwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-darwin-x64": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-darwin-x64/-/ai-hist-native-darwin-x64-0.4.1.tgz", + "integrity": "sha512-EyC94hWSkP4enADhEtrs+pQEyfpTVRh4CVmIXQC4jxLrBRdfcHsgEOUFtnPvzyPKe/9p/GZDX2IYIESfpEbP7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-linux-arm64-gnu": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-linux-arm64-gnu/-/ai-hist-native-linux-arm64-gnu-0.4.1.tgz", + "integrity": "sha512-2TOLTJxvPefs7SavzwZl72p+/B+KkTJUaoxdIqm2pAjGvHO6OPZYa0ga5LniCrSqFcGuGAyLlH5/h9obVbuOmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-linux-arm64-musl": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-linux-arm64-musl/-/ai-hist-native-linux-arm64-musl-0.4.1.tgz", + "integrity": "sha512-sgEluX89qtLD4VXX0x0BwPXUwir6VRrZBFiUwXGz4EFecFu58QUZmgOju0nhw4GiomxC2R3LahGIl/u3w+vhNw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-linux-x64-gnu": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-linux-x64-gnu/-/ai-hist-native-linux-x64-gnu-0.4.1.tgz", + "integrity": "sha512-RdaBksaPPnAhynjA7Hzs+uC3xV9HbJHOj1sfMlpF7qcX8GGn5C7uN8BuFq0qEf3Zq8FsLDKmE7NcMcjs2jLEPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-linux-x64-musl": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-linux-x64-musl/-/ai-hist-native-linux-x64-musl-0.4.1.tgz", + "integrity": "sha512-NHviM8TNIrLWgnzAOzaGXIqJ+N96b0zjjT5ZFdCMIi+DSH9pSiaEoBhkvukso9LcJU1qdVDwg4DEL1JuZwV6gQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -4964,6 +5105,7 @@ "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, "optional": true, "engines": { "node": ">=10.0.0" @@ -5291,6 +5433,7 @@ "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, "hasInstallScript": true, "optional": true, "dependencies": { @@ -7726,6 +7869,7 @@ "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -9969,6 +10113,9 @@ }, "engines": { "node": ">=20.9.0" + }, + "optionalDependencies": { + "ai-hist-native": "^0.4.1" } }, "packages/cli/node_modules/@relaycast/sdk": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 8174de3f1..5e51365e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -61,6 +61,9 @@ "ws": "^8.18.3", "zod": "^3.23.8" }, + "optionalDependencies": { + "ai-hist-native": "^0.4.1" + }, "devDependencies": { "esbuild": "^0.27.2" }, From 529a7a47d4b6e52e5c5e68c78c481d1aa3d9f4d9 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 7 Jul 2026 22:09:23 +0200 Subject: [PATCH 11/11] fix(cli): surface broken ai-hist-native addon instead of silently no-op'ing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reflex-capture's loadNative() caught every dynamic-import error and returned null, so an installed-but-broken addon (ABI mismatch, missing system lib, init failure) looked identical to "not installed" and the capture loop silently did nothing. Now only ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND is treated as a no-op; any other error is rethrown so the loop logs it (`[reflex] cloud sync failed: …`). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/lib/reflex-capture.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/cli/lib/reflex-capture.ts b/packages/cli/src/cli/lib/reflex-capture.ts index 821edfb8f..f9dca0933 100644 --- a/packages/cli/src/cli/lib/reflex-capture.ts +++ b/packages/cli/src/cli/lib/reflex-capture.ts @@ -46,18 +46,26 @@ export interface NativeAiHist { syncAndPush: () => Promise<{ sent: number; accepted: number; authenticated: boolean }>; } -/** Lazily load the `ai-hist-native` napi addon; null if unavailable. */ +/** Lazily load the `ai-hist-native` napi addon; null when it isn't installed. */ async function loadNative(): Promise { // Non-literal spec keeps the native addon out of the esbuild bundle; it // resolves from node_modules (the per-platform optional dep) at runtime. const spec = 'ai-hist-native'; + let mod: Partial & { default?: Partial }; try { - const mod = (await import(spec)) as Partial & { default?: Partial }; - const fn = mod.syncAndPush ?? mod.default?.syncAndPush; - return typeof fn === 'function' ? { syncAndPush: fn } : null; - } catch { - return null; // addon not installed for this platform + mod = (await import(spec)) as typeof mod; + } catch (err) { + // Not installed for this platform → a clean no-op. Anything else (ABI + // mismatch, missing system lib, addon init failure) is a real problem — + // rethrow so the caller logs it instead of silently doing nothing. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') { + return null; + } + throw err; } + const fn = mod.syncAndPush ?? mod.default?.syncAndPush; + return typeof fn === 'function' ? { syncAndPush: fn } : null; } export interface ReflexPushOptions {