Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
"ws": "^8.18.3",
"zod": "^3.23.8"
},
"optionalDependencies": {
"ai-hist-native": "^0.4.1"
},
"devDependencies": {
"esbuild": "^0.27.2"
},
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/cli/commands/reflex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ function createHarness(overrides?: Partial<ReflexDependencies>) {
}

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),
Expand Down Expand Up @@ -82,6 +81,7 @@ describe('registerReflexCommands', () => {
expect.arrayContaining([
'Reflex will capture your agent sessions and sync to history.agentrelay.com',
'Reflex is on.',
'History syncs to relayhistory-cloud automatically while `agent-relay up` is running.',
'State file: ~/.agentworkforce/reflex.json',
])
);
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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.'
);
});
});
78 changes: 30 additions & 48 deletions packages/cli/src/cli/commands/reflex.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
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 { 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 interface ReflexDependencies {
fs: typeof fs;
homedir: () => string;
readRelayAuth: () => Promise<{ accessToken: string } | null>;
loginToCloud: (relayAccessToken: string) => Promise<LoginCloudResult>;
Expand Down Expand Up @@ -106,16 +100,20 @@ async function defaultLoginToCloud(relayAccessToken: string): Promise<LoginCloud
return { ok: false, error: 'Login response missing accessToken' };
}

// Persist rth_at_ tokens so ai-hist sync/push can authenticate on subsequent runs.
// Persist the rth_at_ session where the `ai-hist` Rust binary reads it, so the
// in-process cloud push (which drives `ai-hist push`) authenticates on later
// runs. The binary reads $RELAYHISTORY_HOME/auth.json (default
// ~/.agentworkforce/relayhistory/auth.json) in snake_case.
try {
const configDir = process.env.AI_HIST_CONFIG_DIR ?? path.join(os.homedir(), '.config', 'ai-hist');
const authPath = path.join(configDir, 'auth.json');
const authDir =
process.env.RELAYHISTORY_HOME ?? path.join(os.homedir(), '.agentworkforce', 'relayhistory');
const authPath = path.join(authDir, 'auth.json');
const auth = {
baseUrl: rawBase.replace(/\/$/, ''),
accessToken: payload.accessToken,
...(typeof payload.refreshToken === 'string' ? { refreshToken: payload.refreshToken } : {}),
base_url: rawBase.replace(/\/$/, ''),
access_token: payload.accessToken,
...(typeof payload.refreshToken === 'string' ? { refresh_token: payload.refreshToken } : {}),
};
await mkdir(configDir, { recursive: true });
await mkdir(authDir, { recursive: true });
await writeFile(authPath, JSON.stringify(auth, null, 2));
// Explicitly tighten perms — writeFile mode only applies to newly created files.
await chmod(authPath, 0o600);
Expand All @@ -128,7 +126,6 @@ async function defaultLoginToCloud(relayAccessToken: string): Promise<LoginCloud

function withDefaults(overrides: Partial<ReflexDependencies> = {}): ReflexDependencies {
return {
fs,
homedir: os.homedir,
readRelayAuth: defaultReadRelayAuth,
loginToCloud: defaultLoginToCloud,
Expand All @@ -138,32 +135,6 @@ function withDefaults(overrides: Partial<ReflexDependencies> = {}): ReflexDepend
};
}

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<ReflexDependencies> = {}): void {
const deps = withDefaults(overrides);
const reflex = program.command('reflex').description('Manage Reflex history sync');
Expand All @@ -180,40 +151,51 @@ export function registerReflexCommands(program: Command, overrides: Partial<Refl
return;
}

writeReflexState(deps, {
enabled: true,
enabledAt: new Date().toISOString(),
});
writeReflexState(
{
enabled: true,
enabledAt: new Date().toISOString(),
},
deps.homedir()
);

const relayAuth = await deps.readRelayAuth();
let cloudSyncActive = false;
if (!relayAuth) {
deps.log(
'Not logged in to Agent Relay. Run `agent-relay login` first to sync Reflex history to the cloud.'
);
} else {
const result = await deps.loginToCloud(relayAuth.accessToken);
if (!result.ok) {
if (result.ok) {
cloudSyncActive = true;
} else {
deps.log(`Reflex is enabled locally, but cloud login did not complete: ${result.error}`);
}
}

deps.log('Reflex is on.');
// Only promise automatic cloud sync when cloud auth actually succeeded —
// otherwise the message would contradict the login warning above.
if (cloudSyncActive) {
deps.log('History syncs to relayhistory-cloud automatically while `agent-relay up` is running.');
}
deps.log('State file: ~/.agentworkforce/reflex.json');
});

reflex
.command('off')
.description('Disable Reflex history sync')
.action(() => {
writeReflexState(deps, { enabled: false });
writeReflexState({ enabled: false }, deps.homedir());
deps.log('Reflex is off.');
});

reflex
.command('status')
.description('Show Reflex status')
.action(() => {
const state = readReflexState(deps);
const state = readReflexState(deps.homedir());
if (!state) {
deps.log('Reflex is off (never enabled).');
return;
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/cli/lib/broker-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { errorClassName } from './telemetry-helpers.js';
import { createImplicitLocalFleetNode, createTriggerSyncClient, fleetStatusPath } from './fleet-sidecar.js';
import { discoverNodeConfigPath, loadNodeDefinition } from './node-definition-loader.js';
import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js';

type UpOptions = {
spawn?: boolean;
Expand Down Expand Up @@ -845,7 +846,7 @@
}
}

export async function runUpCommand(options: UpOptions, deps: CoreDependencies): Promise<void> {

Check warning on line 849 in packages/cli/src/cli/lib/broker-lifecycle.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'runUpCommand' has a complexity of 33. Maximum allowed is 15
ensureBundledAgentRelayMcpCommand(deps);

const paths = deps.getProjectPaths();
Expand Down Expand Up @@ -948,6 +949,7 @@

let relay: CoreRelay | null = null;
let fleetSidecar: RunningNode | undefined;
let reflexCapture: RunningReflexCapture | undefined;
let shuttingDown = false;
let sigintCount = 0;
let shutdownPromise: Promise<void> | undefined;
Expand All @@ -958,6 +960,7 @@
shutdownPromise = Promise.resolve();
} else {
shutdownPromise = (async () => {
await reflexCapture?.stop();
await fleetSidecar?.stop();
await shutdownUpResources(relay, paths.dataDir, deps);
})();
Expand Down Expand Up @@ -1010,6 +1013,10 @@
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, nodeDefinition);
// When Reflex is enabled, periodically sync + push local session history to
// relayhistory-cloud in-process via the ai-hist-native addon (no subprocess).
// No-op when disabled or the addon isn't available.
reflexCapture = startReflexCapture({ log: (message) => deps.log(message) });
const shouldSpawn =
options.spawn === true ? true : options.spawn === false ? false : Boolean(teamsConfig?.autoSpawn);

Expand Down
Loading
Loading